mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-16 03:22:47 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d79d058ec | |||
| 186c856d59 | |||
| b2c6e991d8 | |||
| e4ec2470d3 | |||
| 3854f224b0 | |||
| 7008f41aff | |||
| ac04848654 | |||
| 3bd9807e50 | |||
| aedcf87338 | |||
| 8423b6ada1 |
@@ -16,5 +16,4 @@ jobs:
|
||||
with:
|
||||
go-version: "1.24"
|
||||
release-workflow: "release.yml"
|
||||
secrets:
|
||||
pat-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
|
||||
@@ -73,3 +73,23 @@ dockers_v2:
|
||||
extra_files:
|
||||
- config.example.yaml
|
||||
|
||||
signs:
|
||||
- cmd: cosign
|
||||
signature: "${artifact}.sigstore.json"
|
||||
args:
|
||||
- sign-blob
|
||||
- "--bundle=${signature}"
|
||||
- "${artifact}"
|
||||
- "--yes"
|
||||
artifacts: checksum
|
||||
output: true
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
artifacts: manifests
|
||||
output: true
|
||||
args:
|
||||
- sign
|
||||
- "${artifact}@${digest}"
|
||||
- "--yes"
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ $ git-velocity serve --port 8080
|
||||
- **115 Achievements**: Tiered progression from "First Steps" to "Code Warrior"
|
||||
- **Leaderboards**: Compete with your team
|
||||
- **Tier Progression**: Multiple tiers per achievement category
|
||||
- **Activity Patterns**: Track early bird, night owl, weekend, and out-of-hours commits
|
||||
- **Activity Patterns**: Track early bird, night owl, weekend commits with time-based scoring multipliers (x1 to x5)
|
||||
- **Streak Tracking**: Daily streaks and work-week streaks (weekends don't break it!)
|
||||
- **General velocity chart**: Visualize your velocity over time
|
||||
|
||||
@@ -70,7 +70,7 @@ $ git-velocity serve --port 8080
|
||||
- **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
|
||||
- Modern Vue.js SPA with dark theme
|
||||
- Responsive design for desktop and mobile
|
||||
- Interactive charts and visualizations
|
||||
- GitHub Pages deployment ready
|
||||
@@ -95,6 +95,25 @@ go install github.com/lukaszraczylo/git-velocity/cmd/git-velocity@latest
|
||||
# https://github.com/lukaszraczylo/git-velocity/releases
|
||||
```
|
||||
|
||||
### Verifying Release Signatures
|
||||
|
||||
All release checksums and Docker images are signed with [cosign](https://github.com/sigstore/cosign) using keyless signing. To verify:
|
||||
|
||||
```bash
|
||||
# Verify checksum signature
|
||||
cosign verify-blob \
|
||||
--certificate-identity-regexp "https://github.com/lukaszraczylo/git-velocity-analyser/.*" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
--bundle "<checksums-file>.sigstore.json" \
|
||||
<checksums-file>
|
||||
|
||||
# Verify Docker image
|
||||
cosign verify \
|
||||
--certificate-identity-regexp "https://github.com/lukaszraczylo/git-velocity-analyser/.*" \
|
||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
||||
ghcr.io/lukaszraczylo/git-velocity:latest
|
||||
```
|
||||
|
||||
### Create Configuration
|
||||
|
||||
Create `.git-velocity.yaml` in your repository:
|
||||
@@ -123,13 +142,14 @@ teams:
|
||||
scoring:
|
||||
enabled: true
|
||||
points:
|
||||
commit: 10
|
||||
commit: 10 # Base points (multiplied by time of day)
|
||||
commit_with_tests: 15
|
||||
pr_opened: 25
|
||||
pr_merged: 50
|
||||
pr_reviewed: 30
|
||||
fast_review_1h: 50
|
||||
fast_review_4h: 25
|
||||
# Time multipliers: x1 (9-5), x2 (5-9pm, 6-9am), x2.5 (9pm-12am), x5 (12-6am)
|
||||
|
||||
output:
|
||||
directory: "./dist"
|
||||
@@ -407,7 +427,12 @@ scoring:
|
||||
fast_review_1h: 50
|
||||
fast_review_4h: 25
|
||||
fast_review_24h: 10
|
||||
out_of_hours: 2 # Bonus per commit outside 9am-5pm
|
||||
# Time-based commit multipliers (applied to base commit points)
|
||||
multiplier_regular_hours: 1.0 # 9am-5pm
|
||||
multiplier_evening: 2.0 # 5pm-9pm
|
||||
multiplier_late_night: 2.5 # 9pm-midnight
|
||||
multiplier_overnight: 5.0 # midnight-6am
|
||||
multiplier_early_morning: 2.0 # 6am-9am
|
||||
|
||||
output:
|
||||
directory: "./dist"
|
||||
|
||||
@@ -27,10 +27,10 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.1 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.7.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.2 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
@@ -40,7 +40,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-github/v75 v75.0.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
@@ -63,7 +63,7 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
@@ -29,14 +29,14 @@ github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaL
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.6.1 h1:/zMlAezfDzT2xy6acHBzwIfyu2ic0hgkT83UX5EY2gY=
|
||||
github.com/clipperhouse/displaywidth v0.6.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE=
|
||||
github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
|
||||
github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
@@ -65,15 +65,15 @@ github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXe
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s=
|
||||
github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68=
|
||||
github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic=
|
||||
github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
@@ -158,16 +158,15 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -72,6 +72,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Per-repo activity days
|
||||
repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings
|
||||
|
||||
// Track unique files per contributor for accurate FilesChanged count
|
||||
contributorFiles := make(map[string]map[string]bool) // login -> set of file paths
|
||||
// Per-repo unique files per contributor
|
||||
repoContributorFiles := make(map[string]map[string]map[string]bool) // repo -> login -> set of file paths
|
||||
|
||||
// Helper to get or create per-repo contributor
|
||||
getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics {
|
||||
if repoContributorMap[repo] == nil {
|
||||
@@ -141,7 +146,13 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
cm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
|
||||
cm.CommentLinesAdded += commit.CommentAdditions
|
||||
cm.CommentLinesDeleted += commit.CommentDeletions
|
||||
cm.FilesChanged += commit.FilesChanged
|
||||
// Track unique files (don't sum - we'll count unique files at the end)
|
||||
if contributorFiles[login] == nil {
|
||||
contributorFiles[login] = make(map[string]bool)
|
||||
}
|
||||
for _, filePath := range commit.FilesModified {
|
||||
contributorFiles[login][filePath] = true
|
||||
}
|
||||
|
||||
// Update per-repo contributor stats
|
||||
rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL)
|
||||
@@ -152,23 +163,32 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm.MeaningfulLinesDeleted += commit.MeaningfulDeletions
|
||||
rcm.CommentLinesAdded += commit.CommentAdditions
|
||||
rcm.CommentLinesDeleted += commit.CommentDeletions
|
||||
rcm.FilesChanged += commit.FilesChanged
|
||||
// Track unique files per repo (don't sum - we'll count unique files at the end)
|
||||
if repoContributorFiles[commit.Repository] == nil {
|
||||
repoContributorFiles[commit.Repository] = make(map[string]map[string]bool)
|
||||
}
|
||||
if repoContributorFiles[commit.Repository][login] == nil {
|
||||
repoContributorFiles[commit.Repository][login] = make(map[string]bool)
|
||||
}
|
||||
for _, filePath := range commit.FilesModified {
|
||||
repoContributorFiles[commit.Repository][login][filePath] = true
|
||||
}
|
||||
|
||||
// Track activity patterns based on commit time
|
||||
hour := commit.Date.Hour()
|
||||
weekday := commit.Date.Weekday()
|
||||
|
||||
// Early bird: commits before 9am
|
||||
// Early bird: commits before 9am (for achievements)
|
||||
if hour >= 5 && hour < 9 {
|
||||
cm.EarlyBirdCount++
|
||||
rcm.EarlyBirdCount++
|
||||
}
|
||||
// Night owl: commits after 9pm
|
||||
// Night owl: commits after 9pm (for achievements)
|
||||
if hour >= 21 || hour < 5 {
|
||||
cm.NightOwlCount++
|
||||
rcm.NightOwlCount++
|
||||
}
|
||||
// Nosferatu: commits between midnight and 4am
|
||||
// Nosferatu: commits between midnight and 4am (for achievements)
|
||||
if hour >= 0 && hour < 4 {
|
||||
cm.MidnightCount++
|
||||
rcm.MidnightCount++
|
||||
@@ -178,12 +198,41 @@ 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)
|
||||
// Out of hours: commits outside 9am-5pm (legacy, kept for achievements)
|
||||
if hour < 9 || hour >= 17 {
|
||||
cm.OutOfHoursCount++
|
||||
rcm.OutOfHoursCount++
|
||||
}
|
||||
|
||||
// Time-based commit counts for multiplier scoring:
|
||||
// - 9am-5pm (9-16): Regular hours x1
|
||||
// - 5pm-9pm (17-20): Evening x2
|
||||
// - 9pm-midnight (21-23): Late night x2.5
|
||||
// - midnight-6am (0-5): Overnight x5
|
||||
// - 6am-9am (6-8): Early morning x2
|
||||
switch {
|
||||
case hour >= 9 && hour < 17:
|
||||
// Regular hours: 9am-5pm (x1)
|
||||
cm.RegularHoursCount++
|
||||
rcm.RegularHoursCount++
|
||||
case hour >= 17 && hour < 21:
|
||||
// Evening: 5pm-9pm (x2)
|
||||
cm.EveningCount++
|
||||
rcm.EveningCount++
|
||||
case hour >= 21 && hour <= 23:
|
||||
// Late night: 9pm-midnight (x2.5)
|
||||
cm.LateNightCount++
|
||||
rcm.LateNightCount++
|
||||
case hour >= 0 && hour < 6:
|
||||
// Overnight: midnight-6am (x5)
|
||||
cm.OvernightCount++
|
||||
rcm.OvernightCount++
|
||||
case hour >= 6 && hour < 9:
|
||||
// Early morning: 6am-9am (x2)
|
||||
cm.EarlyMorningCount++
|
||||
rcm.EarlyMorningCount++
|
||||
}
|
||||
|
||||
// Track activity days (global)
|
||||
if activityDays[login] == nil {
|
||||
activityDays[login] = make(map[string]bool)
|
||||
@@ -224,6 +273,13 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate unique files changed for each contributor
|
||||
for login, files := range contributorFiles {
|
||||
if cm, ok := contributorMap[login]; ok {
|
||||
cm.FilesChanged = len(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Track PRs with changes requested per contributor
|
||||
prChangesRequested := make(map[string]map[int]bool) // login -> set of PR numbers with changes requested
|
||||
|
||||
@@ -550,6 +606,15 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate unique files changed for per-repo contributors
|
||||
if repoFiles, ok := repoContributorFiles[repo]; ok {
|
||||
for login, files := range repoFiles {
|
||||
if rcm, ok := repoContribs[login]; ok {
|
||||
rcm.FilesChanged = len(files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages for per-repo contributors
|
||||
for login, rcm := range repoContribs {
|
||||
if rcm.PRsMerged > 0 {
|
||||
|
||||
+29
-29
@@ -89,7 +89,14 @@ 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
|
||||
OutOfHours int `yaml:"out_of_hours"` // Legacy: kept for backwards compatibility
|
||||
|
||||
// Time-based commit multipliers (applied to base commit points)
|
||||
MultiplierRegularHours float64 `yaml:"multiplier_regular_hours"` // 9am-5pm (default: 1.0)
|
||||
MultiplierEvening float64 `yaml:"multiplier_evening"` // 5pm-9pm (default: 2.0)
|
||||
MultiplierLateNight float64 `yaml:"multiplier_late_night"` // 9pm-midnight (default: 2.5)
|
||||
MultiplierOvernight float64 `yaml:"multiplier_overnight"` // midnight-6am (default: 5.0)
|
||||
MultiplierEarlyMorning float64 `yaml:"multiplier_early_morning"` // 6am-9am (default: 2.0)
|
||||
}
|
||||
|
||||
// AchievementConfig defines an achievement badge
|
||||
@@ -107,18 +114,6 @@ type AchievementCondition struct {
|
||||
Threshold float64 `yaml:"threshold"`
|
||||
}
|
||||
|
||||
// TierFromThreshold returns the tier level (1-11) based on threshold value
|
||||
// Tiers: 1=1, 2=10, 3=25, 4=50, 5=100, 6=250, 7=500, 8=1000, 9=5000, 10=10000, 11=25000+
|
||||
func TierFromThreshold(threshold float64) int {
|
||||
tiers := []float64{1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000}
|
||||
for i := len(tiers) - 1; i >= 0; i-- {
|
||||
if threshold >= tiers[i] {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// OutputConfig specifies output generation settings
|
||||
type OutputConfig struct {
|
||||
Directory string `yaml:"directory"`
|
||||
@@ -191,22 +186,27 @@ 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: 10,
|
||||
IssueClosed: 20,
|
||||
IssueComment: 5,
|
||||
IssueReference: 5,
|
||||
FastReview1h: 50,
|
||||
FastReview4h: 25,
|
||||
FastReview24h: 10,
|
||||
OutOfHours: 2,
|
||||
Commit: 10,
|
||||
CommitWithTests: 15,
|
||||
LinesAdded: 0.1,
|
||||
LinesDeleted: 0.05,
|
||||
PROpened: 25,
|
||||
PRMerged: 50,
|
||||
PRReviewed: 30,
|
||||
ReviewComment: 5,
|
||||
IssueOpened: 10,
|
||||
IssueClosed: 20,
|
||||
IssueComment: 5,
|
||||
IssueReference: 5,
|
||||
FastReview1h: 50,
|
||||
FastReview4h: 25,
|
||||
FastReview24h: 10,
|
||||
OutOfHours: 0, // Legacy, now replaced by time multipliers
|
||||
MultiplierRegularHours: 1.0,
|
||||
MultiplierEvening: 2.0,
|
||||
MultiplierLateNight: 2.5,
|
||||
MultiplierOvernight: 5.0,
|
||||
MultiplierEarlyMorning: 2.0,
|
||||
},
|
||||
},
|
||||
Output: OutputConfig{
|
||||
|
||||
+115
-74
@@ -5,19 +5,22 @@ import (
|
||||
)
|
||||
|
||||
// IsCommentLine checks if a line is a code comment (should not count as meaningful contribution)
|
||||
// Note: Empty/whitespace lines are NOT comments - use IsWhitespaceLine for those.
|
||||
func IsCommentLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return true // Empty lines don't count
|
||||
return false // Empty lines are whitespace, not comments
|
||||
}
|
||||
|
||||
// Common comment patterns across languages
|
||||
// Order matters for overlapping prefixes (e.g., "///" before "//")
|
||||
commentPrefixes := []string{
|
||||
"///", // Rust/Swift/C# doc comments
|
||||
"//", // C, C++, Java, Go, JS, TS, Swift, Kotlin, etc.
|
||||
"#", // Python, Ruby, Shell, YAML, Perl, etc.
|
||||
"/**", // JSDoc/JavaDoc block start
|
||||
"/*", // 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
|
||||
@@ -33,6 +36,19 @@ func IsCommentLine(line string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// C-style block comment continuation: line starts with * followed by space or end of line
|
||||
// This avoids false positives like "*ptr = value" (pointer dereference)
|
||||
if strings.HasPrefix(trimmed, "*") {
|
||||
if len(trimmed) == 1 {
|
||||
return true // Just "*" alone
|
||||
}
|
||||
// Must be followed by whitespace or common comment characters, not alphanumeric
|
||||
nextChar := trimmed[1]
|
||||
if nextChar == ' ' || nextChar == '\t' || nextChar == '/' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -59,79 +75,104 @@ func IsDocumentationFile(filename string) bool {
|
||||
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)
|
||||
}
|
||||
|
||||
// IsDocCommentLine checks if a line is a documentation comment (JSDoc, JavaDoc, Rust doc, etc.)
|
||||
// These are comments specifically meant to document code, as opposed to regular comments.
|
||||
func IsDocCommentLine(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Documentation comment patterns
|
||||
docPrefixes := []string{
|
||||
"///", // Rust, Swift, C# doc comments
|
||||
"//!", // Rust inner doc comments
|
||||
"/**", // JSDoc, JavaDoc block start
|
||||
"\"\"\"", // Python docstring
|
||||
"'''", // Python docstring
|
||||
}
|
||||
|
||||
for _, prefix := range docPrefixes {
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// JSDoc/JavaDoc continuation lines with annotations (@param, @return, etc.)
|
||||
if strings.HasPrefix(trimmed, "* @") || strings.HasPrefix(trimmed, "* @") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for common doc annotations at the start of a comment
|
||||
if strings.HasPrefix(trimmed, "// @") || strings.HasPrefix(trimmed, "# @") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsCommentedOutCode attempts to detect if a comment line contains commented-out code
|
||||
// rather than an actual comment. This is a heuristic and may have false positives/negatives.
|
||||
func IsCommentedOutCode(line string) bool {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Remove comment prefix to get the content
|
||||
var content string
|
||||
commentPrefixes := []string{"///", "//", "#", "/*", "--", ";"}
|
||||
for _, prefix := range commentPrefixes {
|
||||
if strings.HasPrefix(trimmed, prefix) {
|
||||
content = strings.TrimSpace(trimmed[len(prefix):])
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Heuristics for detecting commented-out code:
|
||||
// 1. Ends with common code patterns
|
||||
codeEndings := []string{";", "{", "}", ")", ",", ":", "=>", "->"}
|
||||
for _, ending := range codeEndings {
|
||||
if strings.HasSuffix(content, ending) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Starts with common code keywords
|
||||
codeKeywords := []string{
|
||||
"if ", "else ", "for ", "while ", "switch ", "case ", "return ", "break", "continue",
|
||||
"const ", "let ", "var ", "func ", "function ", "def ", "class ", "struct ", "type ",
|
||||
"import ", "from ", "package ", "public ", "private ", "protected ", "static ",
|
||||
"async ", "await ", "try ", "catch ", "throw ", "raise ",
|
||||
}
|
||||
contentLower := strings.ToLower(content)
|
||||
for _, keyword := range codeKeywords {
|
||||
if strings.HasPrefix(contentLower, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Contains assignment operators
|
||||
if strings.Contains(content, " = ") || strings.Contains(content, " := ") ||
|
||||
strings.Contains(content, " == ") || strings.Contains(content, " != ") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsRenameOrMove checks if a file change represents a rename or move operation
|
||||
// rather than actual content modification. A rename/move is detected when both
|
||||
// the source (fromName) and destination (toName) paths exist and differ.
|
||||
func IsRenameOrMove(fromName, toName string) bool {
|
||||
return fromName != "" && toName != "" && fromName != toName
|
||||
}
|
||||
|
||||
+210
-262
@@ -12,11 +12,11 @@ func TestIsCommentLine(t *testing.T) {
|
||||
line string
|
||||
expected bool
|
||||
}{
|
||||
// Empty and whitespace
|
||||
{"empty string", "", true},
|
||||
{"whitespace only", " ", true},
|
||||
{"tab only", "\t", true},
|
||||
{"mixed whitespace", " \t ", true},
|
||||
// Empty and whitespace - NOT comments (use IsWhitespaceLine instead)
|
||||
{"empty string", "", false},
|
||||
{"whitespace only", " ", false},
|
||||
{"tab only", "\t", false},
|
||||
{"mixed whitespace", " \t ", false},
|
||||
|
||||
// C-style comments (Go, Java, JS, C++, etc.)
|
||||
{"C single line comment", "// this is a comment", true},
|
||||
@@ -25,6 +25,18 @@ func TestIsCommentLine(t *testing.T) {
|
||||
{"C block end", "*/", true},
|
||||
{"C block continuation", "* continuation", true},
|
||||
{"C block continuation with space", " * continuation", true},
|
||||
{"just asterisk", "*", true},
|
||||
{"asterisk with slash", "*/", true},
|
||||
|
||||
// Pointer dereferences - NOT comments
|
||||
{"pointer dereference", "*ptr = value", false},
|
||||
{"pointer in expression", "*foo.bar", false},
|
||||
{"multiplication", "*result", false},
|
||||
|
||||
// Doc comments
|
||||
{"Rust doc comment", "/// This documents the function", true},
|
||||
{"Rust inner doc", "//! Module documentation", true},
|
||||
{"JSDoc start", "/** @param x the value */", true},
|
||||
|
||||
// Python/Shell comments
|
||||
{"Python comment", "# python comment", true},
|
||||
@@ -61,6 +73,70 @@ func TestIsCommentLine(t *testing.T) {
|
||||
{"Function call", "fmt.Println(x)", false},
|
||||
{"String with slash", `"http://example.com"`, false},
|
||||
{"Code after whitespace", " x := 5", false},
|
||||
|
||||
// Indented code (common in diffs) - NOT comments
|
||||
{"tab indented code", "\tfunc main() {", false},
|
||||
{"space indented code", " if x > 0 {", false},
|
||||
{"deeply indented", "\t\t\t\treturn nil", false},
|
||||
{"mixed indentation", " \t for i := range items {", false},
|
||||
{"indented closing brace", "\t}", false},
|
||||
{"indented method call", " obj.Method()", false},
|
||||
|
||||
// TypeScript/JavaScript specific - NOT comments
|
||||
{"TS interface", "interface User {", false},
|
||||
{"TS type alias", "type Handler = () => void;", false},
|
||||
{"TS arrow function", "const fn = () => {", false},
|
||||
{"TS arrow function with type", "const fn = (x: number): string => {", false},
|
||||
{"JS const", "const x = 5;", false},
|
||||
{"JS let", "let counter = 0;", false},
|
||||
{"JS async", "async function fetch() {", false},
|
||||
{"JS await", "const result = await fetch(url);", false},
|
||||
{"JS template literal", "const msg = `Hello ${name}`;", false},
|
||||
{"JS export", "export default Component;", false},
|
||||
{"JS import", "import { useState } from 'react';", false},
|
||||
{"TS generic", "function identity<T>(arg: T): T {", false},
|
||||
{"React JSX", "<Component prop={value} />", false},
|
||||
{"JSX with children", "<div className=\"container\">", false},
|
||||
|
||||
// TypeScript/JavaScript comments
|
||||
{"TS comment", "// TypeScript comment", true},
|
||||
{"JSDoc block", "/** @type {string} */", true},
|
||||
{"TSDoc", "/** @param name - the user name */", true},
|
||||
|
||||
// Go specific - NOT comments
|
||||
{"Go struct", "type User struct {", false},
|
||||
{"Go interface def", "type Reader interface {", false},
|
||||
{"Go func with receiver", "func (u *User) Name() string {", false},
|
||||
{"Go goroutine", "go processItem(item)", false},
|
||||
{"Go defer", "defer file.Close()", false},
|
||||
{"Go channel send", "ch <- value", false},
|
||||
{"Go channel receive", "value := <-ch", false},
|
||||
{"Go select", "select {", false},
|
||||
{"Go case", "case <-done:", false},
|
||||
{"Go map literal", "m := map[string]int{}", false},
|
||||
{"Go slice literal", "s := []int{1, 2, 3}", false},
|
||||
{"Go error handling", "if err != nil {", false},
|
||||
{"Go short var decl", "x := 5", false},
|
||||
{"Go range", "for i, v := range items {", false},
|
||||
|
||||
// Python specific - NOT comments
|
||||
{"Python def", "def main():", false},
|
||||
{"Python class", "class User:", false},
|
||||
{"Python async def", "async def fetch():", false},
|
||||
{"Python decorator", "@property", false},
|
||||
{"Python with", "with open('file') as f:", false},
|
||||
{"Python try", "try:", false},
|
||||
{"Python except", "except ValueError as e:", false},
|
||||
{"Python lambda", "fn = lambda x: x * 2", false},
|
||||
{"Python list comp", "squares = [x**2 for x in range(10)]", false},
|
||||
{"Python dict comp", "d = {k: v for k, v in items}", false},
|
||||
{"Python f-string", "msg = f\"Hello {name}\"", false},
|
||||
{"Python import from", "from typing import List", false},
|
||||
{"Python type hint", "def greet(name: str) -> str:", false},
|
||||
|
||||
// Python comments
|
||||
{"Python comment with hash", "# This is a comment", true},
|
||||
{"Python inline comment would be code", "x = 5 # inline", false}, // The line starts with code
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -144,217 +220,6 @@ func TestIsDocumentationFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -369,6 +234,17 @@ func TestIsMeaningfulLine(t *testing.T) {
|
||||
{"whitespace line", " ", false},
|
||||
{"python comment", "# comment", false},
|
||||
{"code with leading whitespace", " x := 5", true},
|
||||
|
||||
// Indented code is still meaningful
|
||||
{"tab indented code", "\tfunc main() {", true},
|
||||
{"deeply indented code", "\t\t\treturn result", true},
|
||||
{"space indented code", " if err != nil {", true},
|
||||
{"mixed indentation code", " \t for _, item := range items {", true},
|
||||
{"indented closing brace", "\t\t}", true},
|
||||
|
||||
// Indented comments are still comments (not meaningful)
|
||||
{"indented comment", "\t// TODO: fix this", false},
|
||||
{"space indented comment", " # Python comment", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -379,53 +255,125 @@ func TestIsMeaningfulLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
func TestIsRenameOrMove(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fromName string
|
||||
toName string
|
||||
expected bool
|
||||
}{
|
||||
// Rename/move operations - should return true
|
||||
{"simple rename", "old.go", "new.go", true},
|
||||
{"move to subdirectory", "file.go", "pkg/file.go", true},
|
||||
{"move from subdirectory", "pkg/file.go", "file.go", true},
|
||||
{"rename in subdirectory", "pkg/old.go", "pkg/new.go", true},
|
||||
{"move between directories", "src/file.go", "lib/file.go", true},
|
||||
{"complex path rename", "internal/api/v1/handler.go", "internal/api/v2/handler.go", true},
|
||||
|
||||
+// 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
|
||||
}`
|
||||
// NOT rename/move - should return false
|
||||
{"new file", "", "new.go", false},
|
||||
{"deleted file", "old.go", "", false},
|
||||
{"modify same file", "file.go", "file.go", false},
|
||||
{"both empty", "", "", false},
|
||||
{"same path different case is not rename", "File.go", "File.go", false},
|
||||
|
||||
stats := AnalyzePatch(patch)
|
||||
// Edge cases
|
||||
{"whitespace in path rename", "my file.go", "my-file.go", true},
|
||||
{"deeply nested rename", "a/b/c/d/e/f.go", "a/b/c/d/e/g.go", true},
|
||||
}
|
||||
|
||||
// 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")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsRenameOrMove(tt.fromName, tt.toName)
|
||||
assert.Equal(t, tt.expected, result, "IsRenameOrMove(%q, %q)", tt.fromName, tt.toName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDocCommentLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected bool
|
||||
}{
|
||||
// Documentation comments
|
||||
{"Rust doc comment", "/// This documents the function", true},
|
||||
{"Rust doc with leading space", " /// This documents the function", true},
|
||||
{"Rust inner doc", "//! Module documentation", true},
|
||||
{"JSDoc block start", "/** @param x the value */", true},
|
||||
{"JSDoc block start with space", " /** @param x */", true},
|
||||
{"Python docstring double", "\"\"\"This is a docstring", true},
|
||||
{"Python docstring single", "'''This is a docstring", true},
|
||||
{"JSDoc annotation line", "* @param x the value", true},
|
||||
{"JSDoc annotation with extra space", "* @returns the result", true},
|
||||
{"annotation comment", "// @deprecated use newFunc instead", true},
|
||||
{"Python annotation", "# @param x the value", true},
|
||||
|
||||
// Regular comments - NOT doc comments
|
||||
{"regular C comment", "// this is a comment", false},
|
||||
{"regular Python comment", "# just a comment", false},
|
||||
{"block comment start", "/* start of block */", false},
|
||||
{"block continuation", "* continuation without annotation", false},
|
||||
|
||||
// Empty and whitespace
|
||||
{"empty string", "", false},
|
||||
{"whitespace only", " ", false},
|
||||
|
||||
// Code - NOT doc comments
|
||||
{"Go code", "func main() {", false},
|
||||
{"Python code", "def main():", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsDocCommentLine(tt.line)
|
||||
assert.Equal(t, tt.expected, result, "IsDocCommentLine(%q)", tt.line)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommentedOutCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
expected bool
|
||||
}{
|
||||
// Commented-out code - should return true
|
||||
{"commented variable declaration", "// const x = 5;", true},
|
||||
{"commented function call", "// fmt.Println(x)", true}, // Ends with )
|
||||
{"commented function def", "// func main() {", true},
|
||||
{"commented return", "// return nil", true},
|
||||
{"commented import", "// import fmt", true},
|
||||
{"commented if statement", "// if x > 0 {", true},
|
||||
{"commented else", "// else {", true},
|
||||
{"commented for loop", "// for i := 0; i < 10; i++ {", true},
|
||||
{"commented assignment", "// x = 10", true}, // Contains = operator
|
||||
{"commented with equals", "// x = y + 10;", true}, // Ends with ;
|
||||
{"Python commented code", "# def main():", true}, // colon at end
|
||||
{"commented arrow function", "// const fn = () => {", true},
|
||||
{"commented Go assignment", "// x := 5", true},
|
||||
|
||||
// Regular comments - should return false
|
||||
{"todo comment", "// TODO: fix this", false},
|
||||
{"note comment", "// Note: this is important", false},
|
||||
{"explanation comment", "// This function handles errors", false},
|
||||
{"section comment", "// ============", false},
|
||||
{"url in comment", "// See https://example.com", false},
|
||||
|
||||
// Empty and edge cases
|
||||
{"empty string", "", false},
|
||||
{"just comment prefix", "//", false},
|
||||
{"whitespace only", " ", false},
|
||||
|
||||
// Code (not commented) - should return false
|
||||
{"actual code", "const x = 5;", false},
|
||||
{"actual function", "func main() {", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsCommentedOutCode(tt.line)
|
||||
assert.Equal(t, tt.expected, result, "IsCommentedOutCode(%q)", tt.line)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,34 @@ import "time"
|
||||
|
||||
// Commit represents a Git commit
|
||||
type Commit struct {
|
||||
SHA string `json:"sha"`
|
||||
Message string `json:"message"`
|
||||
Author Author `json:"author"`
|
||||
Committer Author `json:"committer"`
|
||||
Date time.Time `json:"date"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
Repository string `json:"repository"` // owner/repo format
|
||||
URL string `json:"url"`
|
||||
SHA string `json:"sha"`
|
||||
Message string `json:"message"`
|
||||
Author Author `json:"author"`
|
||||
Committer Author `json:"committer"`
|
||||
Date time.Time `json:"date"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
FilesModified []string `json:"files_modified,omitempty"` // List of file paths modified in this commit
|
||||
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
|
||||
// Comment line counts (all types of comments)
|
||||
CommentAdditions int `json:"comment_additions"`
|
||||
CommentDeletions int `json:"comment_deletions"`
|
||||
|
||||
// Documentation comment counts (JSDoc, Rust doc comments, docstrings, etc.)
|
||||
DocCommentAdditions int `json:"doc_comment_additions"`
|
||||
DocCommentDeletions int `json:"doc_comment_deletions"`
|
||||
|
||||
// Commented-out code counts (code that was commented rather than deleted)
|
||||
CommentedCodeAdditions int `json:"commented_code_additions"`
|
||||
CommentedCodeDeletions int `json:"commented_code_deletions"`
|
||||
|
||||
// Derived fields
|
||||
HasTests bool `json:"has_tests"`
|
||||
}
|
||||
|
||||
// TotalChanges returns the total lines changed (additions + deletions)
|
||||
func (c *Commit) TotalChanges() int {
|
||||
return c.Additions + c.Deletions
|
||||
}
|
||||
|
||||
// ShortSHA returns the first 7 characters of the SHA
|
||||
func (c *Commit) ShortSHA() string {
|
||||
if len(c.SHA) >= 7 {
|
||||
return c.SHA[:7]
|
||||
}
|
||||
return c.SHA
|
||||
}
|
||||
|
||||
// ShortMessage returns the first line of the commit message
|
||||
func (c *Commit) ShortMessage() string {
|
||||
for i, r := range c.Message {
|
||||
if r == '\n' {
|
||||
return c.Message[:i]
|
||||
}
|
||||
}
|
||||
return c.Message
|
||||
}
|
||||
|
||||
@@ -63,7 +63,14 @@ type ContributorMetrics struct {
|
||||
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
|
||||
OutOfHoursCount int `json:"out_of_hours_count"` // Commits outside 9am-5pm (legacy, kept for achievements)
|
||||
|
||||
// Time-based commit counts for multiplier scoring
|
||||
RegularHoursCount int `json:"regular_hours_count"` // Commits 9am-5pm (x1 multiplier)
|
||||
EveningCount int `json:"evening_count"` // Commits 5pm-9pm (x2 multiplier)
|
||||
LateNightCount int `json:"late_night_count"` // Commits 9pm-midnight (x2.5 multiplier)
|
||||
OvernightCount int `json:"overnight_count"` // Commits midnight-6am (x5 multiplier)
|
||||
EarlyMorningCount int `json:"early_morning_count"` // Commits 6am-9am (x2 multiplier)
|
||||
|
||||
// Repository participation
|
||||
RepositoriesContributed []string `json:"repositories_contributed,omitempty"`
|
||||
|
||||
@@ -45,81 +45,6 @@ func TestAuthor_DisplayName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommit_TotalChanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
commit := Commit{Additions: 100, Deletions: 50}
|
||||
assert.Equal(t, 150, commit.TotalChanges())
|
||||
}
|
||||
|
||||
func TestCommit_ShortSHA(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sha string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "full SHA",
|
||||
sha: "abc123456789def",
|
||||
expected: "abc1234",
|
||||
},
|
||||
{
|
||||
name: "short SHA",
|
||||
sha: "abc",
|
||||
expected: "abc",
|
||||
},
|
||||
{
|
||||
name: "exactly 7 chars",
|
||||
sha: "abc1234",
|
||||
expected: "abc1234",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
commit := Commit{SHA: tt.sha}
|
||||
assert.Equal(t, tt.expected, commit.ShortSHA())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommit_ShortMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "single line",
|
||||
message: "Fix bug in login",
|
||||
expected: "Fix bug in login",
|
||||
},
|
||||
{
|
||||
name: "multiline",
|
||||
message: "Fix bug in login\n\nThis fixes the issue where users couldn't log in.",
|
||||
expected: "Fix bug in login",
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
message: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
commit := Commit{Message: tt.message}
|
||||
assert.Equal(t, tt.expected, commit.ShortMessage())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRequest_IsMerged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -53,6 +53,18 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
|
||||
existing.IssuesClosed += cm.IssuesClosed
|
||||
existing.IssueComments += cm.IssueComments
|
||||
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
|
||||
// Activity pattern metrics (for achievements)
|
||||
existing.EarlyBirdCount += cm.EarlyBirdCount
|
||||
existing.NightOwlCount += cm.NightOwlCount
|
||||
existing.MidnightCount += cm.MidnightCount
|
||||
existing.WeekendWarrior += cm.WeekendWarrior
|
||||
existing.OutOfHoursCount += cm.OutOfHoursCount
|
||||
// Time-based commit counts (for multiplier scoring)
|
||||
existing.RegularHoursCount += cm.RegularHoursCount
|
||||
existing.EveningCount += cm.EveningCount
|
||||
existing.LateNightCount += cm.LateNightCount
|
||||
existing.OvernightCount += cm.OvernightCount
|
||||
existing.EarlyMorningCount += cm.EarlyMorningCount
|
||||
// Combine unique repositories
|
||||
for _, r := range cm.RepositoriesContributed {
|
||||
if !contains(existing.RepositoriesContributed, r) {
|
||||
@@ -169,8 +181,53 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
|
||||
points := c.config.Scoring.Points
|
||||
breakdown := models.ScoreBreakdown{}
|
||||
|
||||
// Commit points
|
||||
breakdown.Commits = cm.CommitCount * points.Commit
|
||||
// Get multipliers with defaults if not set
|
||||
multRegular := points.MultiplierRegularHours
|
||||
if multRegular == 0 {
|
||||
multRegular = 1.0
|
||||
}
|
||||
multEvening := points.MultiplierEvening
|
||||
if multEvening == 0 {
|
||||
multEvening = 2.0
|
||||
}
|
||||
multLateNight := points.MultiplierLateNight
|
||||
if multLateNight == 0 {
|
||||
multLateNight = 2.5
|
||||
}
|
||||
multOvernight := points.MultiplierOvernight
|
||||
if multOvernight == 0 {
|
||||
multOvernight = 5.0
|
||||
}
|
||||
multEarlyMorning := points.MultiplierEarlyMorning
|
||||
if multEarlyMorning == 0 {
|
||||
multEarlyMorning = 2.0
|
||||
}
|
||||
|
||||
// Commit points with time-based multipliers:
|
||||
// - 9am-5pm: base × 1.0
|
||||
// - 5pm-9pm: base × 2.0
|
||||
// - 9pm-midnight: base × 2.5
|
||||
// - midnight-6am: base × 5.0
|
||||
// - 6am-9am: base × 2.0
|
||||
baseCommitPoints := float64(points.Commit)
|
||||
|
||||
// Check if we have time-based breakdown data
|
||||
timeBasedTotal := cm.RegularHoursCount + cm.EveningCount + cm.LateNightCount +
|
||||
cm.OvernightCount + cm.EarlyMorningCount
|
||||
|
||||
var commitScore float64
|
||||
if timeBasedTotal > 0 {
|
||||
// Use time-based multipliers
|
||||
commitScore = float64(cm.RegularHoursCount)*baseCommitPoints*multRegular +
|
||||
float64(cm.EveningCount)*baseCommitPoints*multEvening +
|
||||
float64(cm.LateNightCount)*baseCommitPoints*multLateNight +
|
||||
float64(cm.OvernightCount)*baseCommitPoints*multOvernight +
|
||||
float64(cm.EarlyMorningCount)*baseCommitPoints*multEarlyMorning
|
||||
} else {
|
||||
// Fallback: use CommitCount with regular hours multiplier (backwards compatibility)
|
||||
commitScore = float64(cm.CommitCount) * baseCommitPoints * multRegular
|
||||
}
|
||||
breakdown.Commits = int(commitScore)
|
||||
|
||||
// Line change points - always use meaningful lines (excluding comments/whitespace)
|
||||
// to accurately reflect actual code contribution
|
||||
@@ -203,7 +260,7 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
|
||||
}
|
||||
}
|
||||
|
||||
// Out of hours bonus (commits outside 9am-5pm)
|
||||
// Out of hours bonus (legacy - kept for backwards compatibility but default is 0)
|
||||
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
|
||||
|
||||
// Calculate total
|
||||
@@ -236,9 +293,11 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
|
||||
case "comment_count":
|
||||
earned = float64(cm.ReviewComments) >= ach.Condition.Threshold
|
||||
case "lines_added":
|
||||
earned = float64(cm.LinesAdded) >= ach.Condition.Threshold
|
||||
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
|
||||
earned = float64(cm.MeaningfulLinesAdded) >= ach.Condition.Threshold
|
||||
case "lines_deleted":
|
||||
earned = float64(cm.LinesDeleted) >= ach.Condition.Threshold
|
||||
// Use meaningful lines to match scoring calculation (excludes comments/whitespace)
|
||||
earned = float64(cm.MeaningfulLinesDeleted) >= ach.Condition.Threshold
|
||||
case "avg_review_time_hours":
|
||||
// For avg review time, lower is better, so lower threshold = harder achievement
|
||||
if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold {
|
||||
|
||||
@@ -452,6 +452,8 @@ func TestCalculator_AllAchievementTypes(t *testing.T) {
|
||||
ReviewComments: 25,
|
||||
LinesAdded: 1500,
|
||||
LinesDeleted: 600,
|
||||
MeaningfulLinesAdded: 1500,
|
||||
MeaningfulLinesDeleted: 600,
|
||||
AvgReviewTime: 1.5,
|
||||
UniqueReviewees: 7,
|
||||
RepositoriesContributed: []string{"owner/repo1", "owner/repo2"},
|
||||
|
||||
+9
-3
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
@@ -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-gBkQ2-yN.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-CEo220ix.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-CUXA-hqC.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.css">
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
|
||||
<div id="app"></div>
|
||||
|
||||
+64
-74
@@ -110,11 +110,6 @@ type CloneOptions struct {
|
||||
Depth int
|
||||
}
|
||||
|
||||
// EnsureCloned ensures a repository is cloned and up to date
|
||||
func (r *Repository) EnsureCloned(ctx context.Context, owner, name, token string) error {
|
||||
return r.EnsureClonedWithOptions(ctx, owner, name, token, nil)
|
||||
}
|
||||
|
||||
// EnsureClonedWithOptions ensures a repository is cloned with specific options
|
||||
func (r *Repository) EnsureClonedWithOptions(ctx context.Context, owner, name, token string, opts *CloneOptions) error {
|
||||
repoPath := r.repoPath(owner, name)
|
||||
@@ -315,17 +310,22 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
Name: c.Committer.Name,
|
||||
Email: c.Committer.Email,
|
||||
},
|
||||
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,
|
||||
Date: commitTime,
|
||||
Additions: stats.Additions,
|
||||
Deletions: stats.Deletions,
|
||||
MeaningfulAdditions: stats.MeaningfulAdditions,
|
||||
MeaningfulDeletions: stats.MeaningfulDeletions,
|
||||
CommentAdditions: stats.CommentAdditions,
|
||||
CommentDeletions: stats.CommentDeletions,
|
||||
DocCommentAdditions: stats.DocCommentAdditions,
|
||||
DocCommentDeletions: stats.DocCommentDeletions,
|
||||
CommentedCodeAdditions: stats.CommentedCodeAdditions,
|
||||
CommentedCodeDeletions: stats.CommentedCodeDeletions,
|
||||
FilesChanged: stats.FilesChanged,
|
||||
FilesModified: stats.FilesModified,
|
||||
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)
|
||||
@@ -358,14 +358,19 @@ func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since
|
||||
|
||||
// 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
|
||||
Additions int
|
||||
Deletions int
|
||||
MeaningfulAdditions int
|
||||
MeaningfulDeletions int
|
||||
CommentAdditions int
|
||||
CommentDeletions int
|
||||
DocCommentAdditions int
|
||||
DocCommentDeletions int
|
||||
CommentedCodeAdditions int
|
||||
CommentedCodeDeletions int
|
||||
FilesChanged int
|
||||
FilesModified []string // List of file paths modified
|
||||
HasTests bool
|
||||
}
|
||||
|
||||
// getCommitStats calculates additions, deletions, files changed for a commit
|
||||
@@ -402,7 +407,7 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
|
||||
filesSet := make(map[string]bool)
|
||||
|
||||
for _, change := range changes {
|
||||
// Get the file path
|
||||
// Get the file path (prefer destination for renames/moves, fallback to source)
|
||||
var filePath string
|
||||
if change.To.Name != "" {
|
||||
filePath = change.To.Name
|
||||
@@ -410,15 +415,24 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
|
||||
filePath = change.From.Name
|
||||
}
|
||||
|
||||
// Skip documentation files
|
||||
// Skip if no file path (shouldn't happen, but defensive)
|
||||
if filePath == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip documentation files entirely
|
||||
if diff.IsDocumentationFile(filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count unique files
|
||||
if !filesSet[filePath] {
|
||||
// Check if this is a rename/move operation
|
||||
isRename := diff.IsRenameOrMove(change.From.Name, change.To.Name)
|
||||
|
||||
// Count unique files (but NOT for renames - the file already existed)
|
||||
if !isRename && !filesSet[filePath] {
|
||||
filesSet[filePath] = true
|
||||
stats.FilesChanged++
|
||||
stats.FilesModified = append(stats.FilesModified, filePath)
|
||||
|
||||
// Check for test files
|
||||
for _, pattern := range testPatterns {
|
||||
@@ -429,13 +443,18 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
|
||||
}
|
||||
}
|
||||
|
||||
// Get patch to count lines
|
||||
// Get patch to count lines (even for renames, there may be content changes)
|
||||
patch, err := change.Patch()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, filePatch := range patch.FilePatches() {
|
||||
// For binary files, skip line counting
|
||||
if filePatch.IsBinary() {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, chunk := range filePatch.Chunks() {
|
||||
content := chunk.Content()
|
||||
lines := strings.Split(content, "\n")
|
||||
@@ -446,18 +465,32 @@ func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) com
|
||||
stats.Additions++
|
||||
if diff.IsMeaningfulLine(line) {
|
||||
stats.MeaningfulAdditions++
|
||||
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
|
||||
} else if diff.IsCommentLine(line) {
|
||||
stats.CommentAdditions++
|
||||
// Further classify the comment type
|
||||
if diff.IsDocCommentLine(line) {
|
||||
stats.DocCommentAdditions++
|
||||
} else if diff.IsCommentedOutCode(line) {
|
||||
stats.CommentedCodeAdditions++
|
||||
}
|
||||
}
|
||||
// Whitespace lines are neither meaningful nor comments
|
||||
}
|
||||
case 2: // Delete
|
||||
for _, line := range lines {
|
||||
stats.Deletions++
|
||||
if diff.IsMeaningfulLine(line) {
|
||||
stats.MeaningfulDeletions++
|
||||
} else if diff.IsCommentLine(line) && !diff.IsWhitespaceLine(line) {
|
||||
} else if diff.IsCommentLine(line) {
|
||||
stats.CommentDeletions++
|
||||
// Further classify the comment type
|
||||
if diff.IsDocCommentLine(line) {
|
||||
stats.DocCommentDeletions++
|
||||
} else if diff.IsCommentedOutCode(line) {
|
||||
stats.CommentedCodeDeletions++
|
||||
}
|
||||
}
|
||||
// Whitespace lines are neither meaningful nor comments
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -495,46 +528,3 @@ func extractLoginFromEmail(email, fallbackName string) string {
|
||||
login = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(login, "-")
|
||||
return login
|
||||
}
|
||||
|
||||
// GetAuthorMappings fetches author login mappings
|
||||
// This helps map commit authors to GitHub usernames
|
||||
func (r *Repository) GetAuthorMappings(ctx context.Context, owner, name string) (map[string]string, error) {
|
||||
repoPath := r.repoPath(owner, name)
|
||||
|
||||
repo, err := git.PlainOpen(repoPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open repository: %w", err)
|
||||
}
|
||||
|
||||
mappings := make(map[string]string)
|
||||
|
||||
// Iterate all commits to collect author mappings
|
||||
commitIter, err := repo.Log(&git.LogOptions{All: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get commit log: %w", err)
|
||||
}
|
||||
|
||||
err = commitIter.ForEach(func(c *object.Commit) error {
|
||||
if _, exists := mappings[c.Author.Email]; !exists {
|
||||
mappings[c.Author.Email] = extractLoginFromEmail(c.Author.Email, c.Author.Name)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to iterate commits: %w", err)
|
||||
}
|
||||
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// Cleanup removes the local clone of a repository
|
||||
func (r *Repository) Cleanup(owner, name string) error {
|
||||
repoPath := r.repoPath(owner, name)
|
||||
return os.RemoveAll(repoPath)
|
||||
}
|
||||
|
||||
// CleanupAll removes all local clones
|
||||
func (r *Repository) CleanupAll() error {
|
||||
return os.RemoveAll(r.baseDir)
|
||||
}
|
||||
|
||||
Vendored
-70
@@ -147,76 +147,6 @@ func (c *NoopCache) Clear() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemoryCache implements in-memory caching (useful for testing)
|
||||
type MemoryCache struct {
|
||||
data map[string]cacheEntry
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMemoryCache creates a new in-memory cache
|
||||
func NewMemoryCache(ttl time.Duration) *MemoryCache {
|
||||
return &MemoryCache{
|
||||
data: make(map[string]cacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the cache
|
||||
func (c *MemoryCache) Get(key string) (interface{}, bool) {
|
||||
c.mu.RLock()
|
||||
entry, ok := c.data[key]
|
||||
if !ok {
|
||||
c.mu.RUnlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Check expiration - if expired, upgrade to write lock to delete
|
||||
if time.Now().After(entry.ExpiresAt) {
|
||||
c.mu.RUnlock()
|
||||
// Upgrade to write lock for deletion
|
||||
c.mu.Lock()
|
||||
// Re-check in case another goroutine already deleted it
|
||||
if entry, ok := c.data[key]; ok && time.Now().After(entry.ExpiresAt) {
|
||||
delete(c.data, key)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
value := entry.Value
|
||||
c.mu.RUnlock()
|
||||
return value, true
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
func (c *MemoryCache) Set(key string, value interface{}) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.data[key] = cacheEntry{
|
||||
Value: value,
|
||||
ExpiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// Delete removes a value from the cache
|
||||
func (c *MemoryCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
delete(c.data, key)
|
||||
}
|
||||
|
||||
// Clear removes all cached values
|
||||
func (c *MemoryCache) Clear() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.data = make(map[string]cacheEntry)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register types for gob encoding
|
||||
func init() {
|
||||
// Register common types that might be cached
|
||||
|
||||
Vendored
-88
@@ -149,93 +149,6 @@ func TestFileCache_CreateDirectory(t *testing.T) {
|
||||
assert.Equal(t, "value", value)
|
||||
}
|
||||
|
||||
func TestMemoryCache_Basic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewMemoryCache(time.Hour)
|
||||
|
||||
// Test Set and Get
|
||||
cache.Set("test-key", "test-value")
|
||||
|
||||
value, ok := cache.Get("test-key")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "test-value", value)
|
||||
}
|
||||
|
||||
func TestMemoryCache_GetNonExistent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewMemoryCache(time.Hour)
|
||||
|
||||
value, ok := cache.Get("non-existent")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
func TestMemoryCache_Expiration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewMemoryCache(50 * time.Millisecond)
|
||||
|
||||
cache.Set("expire-key", "expire-value")
|
||||
|
||||
// Should be available immediately
|
||||
value, ok := cache.Get("expire-key")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "expire-value", value)
|
||||
|
||||
// Wait for expiration
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Should be expired now
|
||||
value, ok = cache.Get("expire-key")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
func TestMemoryCache_Delete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewMemoryCache(time.Hour)
|
||||
|
||||
cache.Set("delete-key", "delete-value")
|
||||
|
||||
// Verify it exists
|
||||
_, ok := cache.Get("delete-key")
|
||||
assert.True(t, ok)
|
||||
|
||||
// Delete it
|
||||
cache.Delete("delete-key")
|
||||
|
||||
// Should be gone
|
||||
value, ok := cache.Get("delete-key")
|
||||
assert.False(t, ok)
|
||||
assert.Nil(t, value)
|
||||
}
|
||||
|
||||
func TestMemoryCache_Clear(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache := NewMemoryCache(time.Hour)
|
||||
|
||||
// Add multiple entries
|
||||
cache.Set("key1", "value1")
|
||||
cache.Set("key2", "value2")
|
||||
cache.Set("key3", "value3")
|
||||
|
||||
// Clear the cache
|
||||
err := cache.Clear()
|
||||
require.NoError(t, err)
|
||||
|
||||
// All should be gone
|
||||
_, ok := cache.Get("key1")
|
||||
assert.False(t, ok)
|
||||
_, ok = cache.Get("key2")
|
||||
assert.False(t, ok)
|
||||
_, ok = cache.Get("key3")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestNoopCache_AlwaysReturnsFalse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -285,6 +198,5 @@ func TestCacheInterface(t *testing.T) {
|
||||
|
||||
// Ensure all cache types implement the interface
|
||||
var _ Cache = (*FileCache)(nil)
|
||||
var _ Cache = (*MemoryCache)(nil)
|
||||
var _ Cache = (*NoopCache)(nil)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
)
|
||||
@@ -182,11 +181,6 @@ func (c *Client) FetchIssuesWithCommentsGraphQL(ctx context.Context, owner, repo
|
||||
return issues, comments, nil
|
||||
}
|
||||
|
||||
// SetRetryConfig sets the retry configuration
|
||||
func (c *Client) SetRetryConfig(rc RetryConfig) {
|
||||
c.retry = rc
|
||||
}
|
||||
|
||||
// retryWithBackoff executes a function with retry logic
|
||||
// - For rate limit errors: waits until the limit resets (no retry count limit)
|
||||
// - For network/transient errors: uses exponential backoff with MaxRetries limit
|
||||
@@ -398,62 +392,6 @@ func (c *Client) GetCommitCountSince(ctx context.Context, owner, repo string, si
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// FetchCommits fetches commits from a repository within a date range
|
||||
func (c *Client) FetchCommits(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Commit, error) {
|
||||
cacheKey := fmt.Sprintf("commits:%s/%s:%v:%v", owner, repo, since, until)
|
||||
|
||||
opts := &github.CommitsListOptions{
|
||||
ListOptions: github.ListOptions{PerPage: 100},
|
||||
}
|
||||
|
||||
if since != nil {
|
||||
opts.Since = *since
|
||||
}
|
||||
if until != nil {
|
||||
opts.Until = *until
|
||||
}
|
||||
|
||||
fetcher := &EnrichingFetcher[*github.RepositoryCommit, models.Commit]{
|
||||
FetchFn: func(ctx context.Context, page int) ([]*github.RepositoryCommit, *github.Response, error) {
|
||||
opts.Page = page
|
||||
var commits []*github.RepositoryCommit
|
||||
var resp *github.Response
|
||||
err := c.retryWithBackoff(ctx, "list commits", func() error {
|
||||
var err error
|
||||
commits, resp, err = c.gh.Repositories.ListCommits(ctx, owner, repo, opts)
|
||||
return err
|
||||
})
|
||||
return commits, resp, err
|
||||
},
|
||||
EnrichFn: func(ctx context.Context, commit *github.RepositoryCommit) (models.Commit, error) {
|
||||
// Fetch detailed commit info for stats
|
||||
var detailed *github.RepositoryCommit
|
||||
err := c.retryWithBackoff(ctx, fmt.Sprintf("get commit %s", commit.GetSHA()[:7]), func() error {
|
||||
var err error
|
||||
detailed, _, err = c.gh.Repositories.GetCommit(ctx, owner, repo, commit.GetSHA(), nil)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return models.Commit{}, err
|
||||
}
|
||||
return convertCommit(detailed, owner, repo), nil
|
||||
},
|
||||
GetDateFn: func(commit *github.RepositoryCommit) time.Time {
|
||||
if commit.Commit != nil && commit.Commit.Author != nil {
|
||||
return commit.Commit.Author.GetDate().Time
|
||||
}
|
||||
return time.Time{}
|
||||
},
|
||||
Since: since,
|
||||
Until: until,
|
||||
}
|
||||
|
||||
config := DefaultFetchConfig("commits")
|
||||
config.EarlyTermination = false // GitHub API already filters by since/until
|
||||
|
||||
return FetchAllPagesWithEnrichment(ctx, c, cacheKey, config, fetcher, 10)
|
||||
}
|
||||
|
||||
// mainBranches are the branches we consider as "main" branches
|
||||
var mainBranches = []string{"main", "master", "develop", "dev"}
|
||||
|
||||
@@ -739,101 +677,6 @@ func (c *Client) FetchUserProfiles(ctx context.Context, logins []string) (map[st
|
||||
|
||||
// Helper functions
|
||||
|
||||
func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit {
|
||||
var author models.Author
|
||||
if c.Author != nil {
|
||||
author = models.Author{
|
||||
Login: c.Author.GetLogin(),
|
||||
AvatarURL: c.Author.GetAvatarURL(),
|
||||
}
|
||||
}
|
||||
if c.Commit != nil && c.Commit.Author != nil {
|
||||
author.Name = c.Commit.Author.GetName()
|
||||
author.Email = c.Commit.Author.GetEmail()
|
||||
}
|
||||
|
||||
var committer models.Author
|
||||
if c.Committer != nil {
|
||||
committer = models.Author{
|
||||
Login: c.Committer.GetLogin(),
|
||||
AvatarURL: c.Committer.GetAvatarURL(),
|
||||
}
|
||||
}
|
||||
if c.Commit != nil && c.Commit.Committer != nil {
|
||||
committer.Name = c.Commit.Committer.GetName()
|
||||
committer.Email = c.Commit.Committer.GetEmail()
|
||||
}
|
||||
|
||||
var date time.Time
|
||||
if c.Commit != nil && c.Commit.Author != nil {
|
||||
date = c.Commit.Author.GetDate().Time
|
||||
}
|
||||
|
||||
var additions, deletions, filesChanged int
|
||||
if c.Stats != nil {
|
||||
additions = c.Stats.GetAdditions()
|
||||
deletions = c.Stats.GetDeletions()
|
||||
}
|
||||
filesChanged = len(c.Files)
|
||||
|
||||
// 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.") ||
|
||||
strings.Contains(filename, "/tests/") ||
|
||||
strings.Contains(filename, "/test/") ||
|
||||
strings.Contains(filename, "__tests__") {
|
||||
hasTests = true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if c.Commit != nil {
|
||||
message = c.Commit.GetMessage()
|
||||
}
|
||||
|
||||
return models.Commit{
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func convertPullRequest(pr *github.PullRequest, owner, repo string) models.PullRequest {
|
||||
var author models.Author
|
||||
if pr.User != nil {
|
||||
|
||||
@@ -204,121 +204,3 @@ func (f *DateFilteredFetcher[T, R]) ShouldSkip(item T) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithRetry wraps a fetch function with retry logic
|
||||
func (c *Client) WithRetry(ctx context.Context, operation string, fn func() error) error {
|
||||
return c.retryWithBackoff(ctx, operation, fn)
|
||||
}
|
||||
|
||||
// EnrichingFetcher extends DateFilteredFetcher with per-item enrichment
|
||||
// This is useful when you need to fetch additional details for each item (e.g., commit details)
|
||||
type EnrichingFetcher[T any, R any] struct {
|
||||
FetchFn func(ctx context.Context, page int) ([]T, *github.Response, error)
|
||||
EnrichFn func(ctx context.Context, item T) (R, error) // Enriches and converts in one step
|
||||
GetDateFn func(item T) time.Time
|
||||
SkipFn func(item T) bool
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
func (f *EnrichingFetcher[T, R]) Fetch(ctx context.Context, page int) ([]T, *github.Response, error) {
|
||||
return f.FetchFn(ctx, page)
|
||||
}
|
||||
|
||||
func (f *EnrichingFetcher[T, R]) Convert(item T) R {
|
||||
// This won't be used - FetchAllPagesWithEnrichment handles enrichment
|
||||
var zero R
|
||||
return zero
|
||||
}
|
||||
|
||||
func (f *EnrichingFetcher[T, R]) Filter(item T) DateFilterResult {
|
||||
return FilterByDate(f.GetDateFn(item), f.Since, f.Until)
|
||||
}
|
||||
|
||||
func (f *EnrichingFetcher[T, R]) ShouldSkip(item T) bool {
|
||||
if f.SkipFn != nil {
|
||||
return f.SkipFn(item)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FetchAllPagesWithEnrichment is like FetchAllPages but calls EnrichFn for each item
|
||||
// This is useful when you need to make additional API calls per item (e.g., fetching commit details)
|
||||
func FetchAllPagesWithEnrichment[T any, R any](
|
||||
ctx context.Context,
|
||||
c *Client,
|
||||
cacheKey string,
|
||||
config FetchConfig,
|
||||
fetcher *EnrichingFetcher[T, R],
|
||||
progressEvery int, // Report progress every N items (0 = disabled)
|
||||
) ([]R, error) {
|
||||
// Check cache first
|
||||
if cacheKey != "" {
|
||||
if cached, ok := c.cache.Get(cacheKey); ok {
|
||||
if results, ok := cached.([]R); ok {
|
||||
c.progress(fmt.Sprintf(" Using cached %s data", config.ResourceName))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allResults []R
|
||||
page := 1
|
||||
|
||||
for {
|
||||
items, resp, err := fetcher.Fetch(ctx, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch %s: %w", config.ResourceName, err)
|
||||
}
|
||||
|
||||
// Safety check for nil response
|
||||
if resp == nil {
|
||||
break
|
||||
}
|
||||
|
||||
if !config.Quiet {
|
||||
c.progress(fmt.Sprintf(" Fetching %s page %d (%d %s so far)...",
|
||||
config.ResourceName, page, len(allResults), config.ResourceName))
|
||||
}
|
||||
|
||||
itemsInPage := 0
|
||||
for i, item := range items {
|
||||
// Skip items that should be filtered out entirely
|
||||
if fetcher.ShouldSkip(item) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply date filtering
|
||||
if fetcher.Filter(item) != DateInclude {
|
||||
continue
|
||||
}
|
||||
|
||||
// Enrich the item (this may make additional API calls)
|
||||
enriched, err := fetcher.EnrichFn(ctx, item)
|
||||
if err != nil {
|
||||
c.progress(fmt.Sprintf(" Warning: failed to enrich item: %v", err))
|
||||
continue
|
||||
}
|
||||
|
||||
allResults = append(allResults, enriched)
|
||||
itemsInPage++
|
||||
|
||||
// Progress reporting
|
||||
if progressEvery > 0 && (i+1)%progressEvery == 0 {
|
||||
c.progress(fmt.Sprintf(" Processing item %d/%d on page %d...", i+1, len(items), page))
|
||||
}
|
||||
}
|
||||
|
||||
if resp.NextPage == 0 {
|
||||
break
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
|
||||
// Cache results
|
||||
if cacheKey != "" {
|
||||
c.cache.Set(cacheKey, allResults)
|
||||
}
|
||||
|
||||
return allResults, nil
|
||||
}
|
||||
|
||||
@@ -88,8 +88,3 @@ func (s *Server) CreateHandler() (http.Handler, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -269,13 +269,6 @@ func TestServer_GetAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
|
||||
<Avatar :name="member" size="sm" />
|
||||
<template v-for="member in (team.member_metrics || []).slice(0, 5)" :key="member.login">
|
||||
<Avatar :name="member.name || member.login" :src="member.avatar_url" size="sm" />
|
||||
</template>
|
||||
<span
|
||||
v-if="(team.members?.length || 0) > 5"
|
||||
v-if="(team.member_metrics?.length || 0) > 5"
|
||||
class="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ team.members.length - 5 }}
|
||||
+{{ team.member_metrics.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,17 +64,23 @@ import SectionHeader from '../components/SectionHeader.vue'
|
||||
Score Formula
|
||||
</h3>
|
||||
<div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
|
||||
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Bonuses
|
||||
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response
|
||||
|
||||
Where:
|
||||
Commits = commit_count x 10 pts
|
||||
Commits = sum of (commits x 10 x time_multiplier)
|
||||
Lines = (added x 0.1) + (deleted x 0.05) pts
|
||||
PRs = (opened x 25) + (merged x 50) pts
|
||||
Reviews = reviews_given x 30 pts
|
||||
Comments = review_comments x 5 pts
|
||||
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
|
||||
Response = fast review bonus (0-50 pts)
|
||||
Out of Hrs = commits outside 9-5 x 2 pts</code></pre>
|
||||
|
||||
Time Multipliers:
|
||||
9am - 5pm = x1 (regular hours)
|
||||
5pm - 9pm = x2 (evening)
|
||||
9pm - midnight = x2.5 (late night)
|
||||
midnight - 6am = x5 (overnight)
|
||||
6am - 9am = x2 (early morning)</code></pre>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-400">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
@@ -167,13 +173,46 @@ Where:
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</span>
|
||||
</div>
|
||||
<!-- Time Multipliers Header -->
|
||||
<div class="col-span-1 py-2 px-3 bg-gray-700/50 rounded-lg text-center">
|
||||
<span class="text-xs font-semibold text-gray-300 uppercase tracking-wide">Time Multipliers</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-moon text-gray-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Out of Hours</span>
|
||||
<i class="fas fa-sun text-yellow-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">9am - 5pm</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">2 pts</span>
|
||||
<span class="font-mono font-bold text-gray-400">x1</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-cloud-sun text-orange-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">5pm - 9pm</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-orange-400">x2</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-moon text-indigo-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">9pm - midnight</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-indigo-400">x2.5</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-star text-purple-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">midnight - 6am</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-purple-400">x5</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-mug-hot text-amber-400"></i>
|
||||
<span class="text-sm font-medium text-gray-100">6am - 9am</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-amber-400">x2</span>
|
||||
</div>
|
||||
<!-- Issues Section -->
|
||||
<div class="flex items-center justify-between p-3 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-circle-exclamation text-teal-500"></i>
|
||||
@@ -217,7 +256,7 @@ Where:
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-commit text-primary-500 mr-2"></i>Commit</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Per commit pushed</td>
|
||||
<td class="py-3">Base points per commit (multiplied by time of day)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-flask text-green-500 mr-2"></i>Commit with Tests</td>
|
||||
@@ -269,11 +308,38 @@ Where:
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Bonus for average response under 24 hours</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-moon text-gray-500 mr-2"></i>Out of Hours</td>
|
||||
<td class="py-3 font-mono text-primary-400">2</td>
|
||||
<td class="py-3">Per commit outside 9am-5pm</td>
|
||||
<!-- Time Multipliers Section -->
|
||||
<tr class="border-b border-gray-700 bg-gray-800/30">
|
||||
<td class="py-3 font-semibold text-gray-200" colspan="3">
|
||||
<i class="fas fa-clock mr-2 text-primary-400"></i>Time Multipliers (applied to commit points)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3 pl-6"><i class="fas fa-sun text-yellow-400 mr-2"></i>9am - 5pm</td>
|
||||
<td class="py-3 font-mono text-gray-400">x1</td>
|
||||
<td class="py-3">Regular working hours</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3 pl-6"><i class="fas fa-cloud-sun text-orange-400 mr-2"></i>5pm - 9pm</td>
|
||||
<td class="py-3 font-mono text-orange-400">x2</td>
|
||||
<td class="py-3">Evening commits</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3 pl-6"><i class="fas fa-moon text-indigo-400 mr-2"></i>9pm - midnight</td>
|
||||
<td class="py-3 font-mono text-indigo-400">x2.5</td>
|
||||
<td class="py-3">Late night commits</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3 pl-6"><i class="fas fa-star text-purple-400 mr-2"></i>midnight - 6am</td>
|
||||
<td class="py-3 font-mono text-purple-400">x5</td>
|
||||
<td class="py-3">Overnight commits (night shift bonus!)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3 pl-6"><i class="fas fa-mug-hot text-amber-400 mr-2"></i>6am - 9am</td>
|
||||
<td class="py-3 font-mono text-amber-400">x2</td>
|
||||
<td class="py-3">Early morning commits</td>
|
||||
</tr>
|
||||
<!-- Issues Section -->
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-circle-exclamation text-teal-500 mr-2"></i>Issue Opened</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
|
||||
Reference in New Issue
Block a user