mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-16 03:22:47 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee7cfaee4 | |||
| 7f27aa6378 | |||
| 0c0c464fbc | |||
| 0aac0b76c0 | |||
| 45adbbe84f | |||
| 63825bdfd4 | |||
| 5ca802cae7 | |||
| 4dc9dc3d07 | |||
| 7fc413ba92 | |||
| 821891a890 | |||
| 063b5acfbb | |||
| e2bd053906 | |||
| 7ba4d438dd | |||
| a23915c620 | |||
| 8d79d058ec | |||
| 186c856d59 | |||
| b2c6e991d8 | |||
| e4ec2470d3 | |||
| 3854f224b0 | |||
| 7008f41aff | |||
| ac04848654 | |||
| 3bd9807e50 | |||
| aedcf87338 | |||
| 8423b6ada1 | |||
| 4aab8af16f | |||
| a5d69ccb86 | |||
| a5b522c996 | |||
| 09b0c533b4 |
@@ -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"
|
||||
|
||||
@@ -4,15 +4,15 @@ go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0
|
||||
github.com/charmbracelet/bubbles v0.21.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/go-git/go-git/v5 v5.16.4
|
||||
github.com/go-git/go-git/v5 v5.16.5
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/go-github/v68 v68.0.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7
|
||||
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
@@ -22,15 +22,14 @@ require (
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.3 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.6.1 // 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/clipperhouse/displaywidth v0.10.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // 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 +39,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
|
||||
@@ -56,14 +55,14 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect
|
||||
github.com/skeema/knownhosts v1.3.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
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/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
@@ -13,30 +13,28 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
|
||||
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY=
|
||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
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/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/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+UraiZGD4HJQ3Y8g=
|
||||
github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos=
|
||||
github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/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=
|
||||
@@ -57,23 +55,23 @@ github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9n
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
|
||||
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
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=
|
||||
@@ -118,10 +116,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed h1:KT7hI8vYXgU0s2qaMkrfq9tCA1w/iEPgfredVP+4Tzw=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
|
||||
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf h1:o1uxfymjZ7jZ4MsgCErcwWGtVKSiNAXtS59Lhs6uI/g=
|
||||
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
|
||||
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
|
||||
@@ -141,15 +139,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -158,16 +156,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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.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/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package aggregator
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -72,6 +73,38 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Per-repo activity days
|
||||
repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings
|
||||
|
||||
// Helper to track activity day for a contributor
|
||||
trackActivityDay := func(login, repo string, date time.Time) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
// Global activity tracking
|
||||
if activityDays[login] == nil {
|
||||
activityDays[login] = make(map[string]bool)
|
||||
}
|
||||
activityDays[login][dateStr] = true
|
||||
// Per-repo activity tracking
|
||||
if repo != "" {
|
||||
if repoActivityDays[repo] == nil {
|
||||
repoActivityDays[repo] = make(map[string]map[string]bool)
|
||||
}
|
||||
if repoActivityDays[repo][login] == nil {
|
||||
repoActivityDays[repo][login] = make(map[string]bool)
|
||||
}
|
||||
repoActivityDays[repo][login][dateStr] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Track unique files per contributor for accurate FilesChanged count
|
||||
contributorFiles := make(map[string]map[string]bool) // login -> set of file paths
|
||||
// Per-repo unique files per contributor
|
||||
repoContributorFiles := make(map[string]map[string]map[string]bool) // repo -> login -> set of file paths
|
||||
|
||||
// Track counts of items with valid time data (for accurate average calculations)
|
||||
// These track only PRs/reviews that have valid time data, not total counts
|
||||
reviewsWithResponseTime := make(map[string]int) // login -> count of reviews with valid ResponseTime
|
||||
repoReviewsWithResponseTime := make(map[string]map[string]int) // repo -> login -> count
|
||||
prsWithTimeToMerge := make(map[string]int) // login -> count of PRs with valid TimeToMerge
|
||||
repoPRsWithTimeToMerge := make(map[string]map[string]int) // repo -> login -> count
|
||||
|
||||
// Helper to get or create per-repo contributor
|
||||
getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics {
|
||||
if repoContributorMap[repo] == nil {
|
||||
@@ -135,40 +168,62 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
cm := contributorMap[login]
|
||||
cm.CommitCount++
|
||||
if commit.HasTests {
|
||||
cm.CommitsWithTests++
|
||||
}
|
||||
cm.LinesAdded += commit.Additions
|
||||
cm.LinesDeleted += commit.Deletions
|
||||
cm.MeaningfulLinesAdded += commit.MeaningfulAdditions
|
||||
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)
|
||||
rcm.CommitCount++
|
||||
if commit.HasTests {
|
||||
rcm.CommitsWithTests++
|
||||
}
|
||||
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 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
|
||||
if hour >= 5 && hour < 9 {
|
||||
// Early bird: commits between 6am-9am (for achievements)
|
||||
// Aligned with the early morning multiplier range
|
||||
if hour >= 6 && hour < 9 {
|
||||
cm.EarlyBirdCount++
|
||||
rcm.EarlyBirdCount++
|
||||
}
|
||||
// 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,30 +233,46 @@ 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++
|
||||
}
|
||||
|
||||
// Track activity days (global)
|
||||
if activityDays[login] == nil {
|
||||
activityDays[login] = make(map[string]bool)
|
||||
// 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++
|
||||
}
|
||||
dateStr := commit.Date.Format("2006-01-02")
|
||||
activityDays[login][dateStr] = true
|
||||
|
||||
// Track activity days (per-repo)
|
||||
if repoActivityDays[commit.Repository] == nil {
|
||||
repoActivityDays[commit.Repository] = make(map[string]map[string]bool)
|
||||
}
|
||||
if repoActivityDays[commit.Repository][login] == nil {
|
||||
repoActivityDays[commit.Repository][login] = make(map[string]bool)
|
||||
}
|
||||
repoActivityDays[commit.Repository][login][dateStr] = true
|
||||
// Track activity day for this commit
|
||||
trackActivityDay(login, commit.Repository, commit.Date)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, commit.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, commit.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository)
|
||||
}
|
||||
|
||||
@@ -224,6 +295,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
|
||||
|
||||
@@ -251,6 +329,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL)
|
||||
rcm.PRsOpened++
|
||||
|
||||
// Track activity day for PR creation
|
||||
trackActivityDay(login, pr.Repository, pr.CreatedAt)
|
||||
|
||||
prSize := pr.Additions + pr.Deletions
|
||||
|
||||
if pr.IsMerged() {
|
||||
@@ -260,6 +341,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
// Accumulate for average calculation
|
||||
cm.AvgTimeToMerge += pr.TimeToMerge.Hours()
|
||||
rcm.AvgTimeToMerge += pr.TimeToMerge.Hours()
|
||||
// Track count of PRs with valid time data for accurate average
|
||||
prsWithTimeToMerge[login]++
|
||||
if repoPRsWithTimeToMerge[pr.Repository] == nil {
|
||||
repoPRsWithTimeToMerge[pr.Repository] = make(map[string]int)
|
||||
}
|
||||
repoPRsWithTimeToMerge[pr.Repository][login]++
|
||||
}
|
||||
|
||||
// Track largest PR
|
||||
@@ -281,7 +368,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
}
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, pr.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, pr.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository)
|
||||
}
|
||||
|
||||
@@ -316,6 +403,9 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
rcm.ReviewsGiven++
|
||||
rcm.ReviewComments += review.CommentsCount
|
||||
|
||||
// Track activity day for review submission
|
||||
trackActivityDay(login, review.Repository, review.SubmittedAt)
|
||||
|
||||
if review.IsApproval() {
|
||||
cm.ApprovalsGiven++
|
||||
rcm.ApprovalsGiven++
|
||||
@@ -339,6 +429,12 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
if review.ResponseTime != nil {
|
||||
cm.AvgReviewTime += review.ResponseTime.Hours()
|
||||
rcm.AvgReviewTime += review.ResponseTime.Hours()
|
||||
// Track count of reviews with valid time data for accurate average
|
||||
reviewsWithResponseTime[login]++
|
||||
if repoReviewsWithResponseTime[review.Repository] == nil {
|
||||
repoReviewsWithResponseTime[review.Repository] = make(map[string]int)
|
||||
}
|
||||
repoReviewsWithResponseTime[review.Repository][login]++
|
||||
}
|
||||
|
||||
// Track unique reviewees
|
||||
@@ -396,21 +492,47 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
cm := contributorMap[login]
|
||||
cm.IssuesOpened++
|
||||
|
||||
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
|
||||
cm.IssuesClosed++
|
||||
}
|
||||
// Track activity day for issue creation
|
||||
trackActivityDay(login, issue.Repository, issue.CreatedAt)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
|
||||
}
|
||||
|
||||
// Update per-repo contributor metrics
|
||||
rcm := getRepoContributor(issue.Repository, login, cm.Name, cm.AvatarURL)
|
||||
rcm.IssuesOpened++
|
||||
if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login {
|
||||
rcm.IssuesClosed++
|
||||
}
|
||||
|
||||
// Count issues closed by each contributor (separate from who opened them)
|
||||
// This gives credit to whoever closed the issue, even if they didn't open it
|
||||
for _, issue := range data.Issues {
|
||||
if !issue.IsClosed() || issue.ClosedBy == nil || issue.ClosedBy.Login == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
closerLogin := issue.ClosedBy.Login
|
||||
|
||||
// Initialize contributor if needed (someone who closes issues but didn't open any)
|
||||
if _, ok := contributorMap[closerLogin]; !ok {
|
||||
contributorMap[closerLogin] = &models.ContributorMetrics{
|
||||
Login: closerLogin,
|
||||
Period: period,
|
||||
}
|
||||
}
|
||||
|
||||
cm := contributorMap[closerLogin]
|
||||
cm.IssuesClosed++
|
||||
|
||||
// Track repository participation for the closer
|
||||
if !slices.Contains(cm.RepositoriesContributed, issue.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository)
|
||||
}
|
||||
|
||||
// Update per-repo contributor metrics for the closer
|
||||
rcm := getRepoContributor(issue.Repository, closerLogin, cm.Name, cm.AvatarURL)
|
||||
rcm.IssuesClosed++
|
||||
}
|
||||
|
||||
// Process issue comments
|
||||
@@ -431,8 +553,11 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
cm := contributorMap[login]
|
||||
cm.IssueComments++
|
||||
|
||||
// Track activity day for issue comment
|
||||
trackActivityDay(login, comment.Repository, comment.CreatedAt)
|
||||
|
||||
// Track repository participation
|
||||
if !contains(cm.RepositoriesContributed, comment.Repository) {
|
||||
if !slices.Contains(cm.RepositoriesContributed, comment.Repository) {
|
||||
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
|
||||
}
|
||||
|
||||
@@ -494,20 +619,23 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
|
||||
// Calculate averages and finalize contributor metrics
|
||||
for login, cm := range contributorMap {
|
||||
// Calculate average time to merge
|
||||
// Calculate average time to merge (only from PRs that have TimeToMerge data)
|
||||
if count := prsWithTimeToMerge[login]; count > 0 {
|
||||
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(count)
|
||||
}
|
||||
|
||||
// Calculate average review time (only from reviews that have ResponseTime data)
|
||||
if count := reviewsWithResponseTime[login]; count > 0 {
|
||||
cm.AvgReviewTime = cm.AvgReviewTime / float64(count)
|
||||
}
|
||||
|
||||
// Calculate average PR size (only for merged PRs to exclude abandoned PRs)
|
||||
if cm.PRsMerged > 0 {
|
||||
cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(cm.PRsMerged)
|
||||
}
|
||||
|
||||
// Calculate average review time
|
||||
if cm.ReviewsGiven > 0 {
|
||||
cm.AvgReviewTime = cm.AvgReviewTime / float64(cm.ReviewsGiven)
|
||||
}
|
||||
|
||||
// Calculate average PR size
|
||||
if cm.PRsOpened > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if !pr.IsMerged() {
|
||||
continue // Only count merged PRs
|
||||
}
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if normalized, ok := prAuthorToNormalizedLogin[prLogin]; ok {
|
||||
@@ -517,7 +645,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsOpened)
|
||||
cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsMerged)
|
||||
}
|
||||
|
||||
// Set unique reviewees count
|
||||
@@ -550,19 +678,37 @@ 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 {
|
||||
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(rcm.PRsMerged)
|
||||
// Use count of PRs with valid time data for accurate average
|
||||
if repoPRCounts, ok := repoPRsWithTimeToMerge[repo]; ok {
|
||||
if count := repoPRCounts[login]; count > 0 {
|
||||
rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(count)
|
||||
}
|
||||
}
|
||||
if rcm.ReviewsGiven > 0 {
|
||||
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven)
|
||||
// Use count of reviews with valid time data for accurate average
|
||||
if repoReviewCounts, ok := repoReviewsWithResponseTime[repo]; ok {
|
||||
if count := repoReviewCounts[login]; count > 0 {
|
||||
rcm.AvgReviewTime = rcm.AvgReviewTime / float64(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average PR size for this repo
|
||||
if rcm.PRsOpened > 0 {
|
||||
// Calculate average PR size for this repo (only for merged PRs to exclude abandoned PRs)
|
||||
if rcm.PRsMerged > 0 {
|
||||
totalPRLines := 0
|
||||
for _, pr := range data.PullRequests {
|
||||
if !pr.IsMerged() {
|
||||
continue // Only count merged PRs
|
||||
}
|
||||
// Normalize PR author login before comparison
|
||||
prLogin := pr.Author.Login
|
||||
if mapped, ok := loginToLogin[prLogin]; ok {
|
||||
@@ -572,7 +718,7 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
|
||||
totalPRLines += pr.TotalChanges()
|
||||
}
|
||||
}
|
||||
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsOpened)
|
||||
rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsMerged)
|
||||
}
|
||||
|
||||
// Calculate perfect PRs for this repo
|
||||
@@ -696,15 +842,6 @@ func parseRepoName(fullName string) (owner, name string) {
|
||||
return fullName, ""
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeForComparison normalizes a string for fuzzy comparison
|
||||
// by lowercasing and removing spaces, hyphens, underscores, dots, and digits
|
||||
func normalizeForComparison(s string) string {
|
||||
@@ -1217,7 +1354,47 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
|
||||
pointsReview = 30
|
||||
}
|
||||
|
||||
// Aggregate commits by week
|
||||
// Get time-based multipliers with defaults
|
||||
multRegular := scoringConfig.Points.MultiplierRegularHours
|
||||
if multRegular == 0 {
|
||||
multRegular = 1.0
|
||||
}
|
||||
multEvening := scoringConfig.Points.MultiplierEvening
|
||||
if multEvening == 0 {
|
||||
multEvening = 2.0
|
||||
}
|
||||
multLateNight := scoringConfig.Points.MultiplierLateNight
|
||||
if multLateNight == 0 {
|
||||
multLateNight = 2.5
|
||||
}
|
||||
multOvernight := scoringConfig.Points.MultiplierOvernight
|
||||
if multOvernight == 0 {
|
||||
multOvernight = 5.0
|
||||
}
|
||||
multEarlyMorning := scoringConfig.Points.MultiplierEarlyMorning
|
||||
if multEarlyMorning == 0 {
|
||||
multEarlyMorning = 2.0
|
||||
}
|
||||
|
||||
// Helper to get time-based multiplier for a commit
|
||||
getTimeMultiplier := func(hour int) float64 {
|
||||
switch {
|
||||
case hour >= 9 && hour < 17:
|
||||
return multRegular // Regular hours: 9am-5pm
|
||||
case hour >= 17 && hour < 21:
|
||||
return multEvening // Evening: 5pm-9pm
|
||||
case hour >= 21 && hour <= 23:
|
||||
return multLateNight // Late night: 9pm-midnight
|
||||
case hour >= 0 && hour < 6:
|
||||
return multOvernight // Overnight: midnight-6am
|
||||
case hour >= 6 && hour < 9:
|
||||
return multEarlyMorning // Early morning: 6am-9am
|
||||
default:
|
||||
return multRegular
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate commits by week (with time-based multipliers)
|
||||
for _, commit := range data.Commits {
|
||||
if commit.Date.Before(start) || commit.Date.After(end) {
|
||||
continue
|
||||
@@ -1225,7 +1402,9 @@ func buildVelocityTimeline(data *models.RawData, period models.Period, scoringCo
|
||||
idx := findWeekIndex(commit.Date)
|
||||
if idx >= 0 && idx < len(weeks) {
|
||||
weekCommits[idx]++
|
||||
weekScore[idx] += float64(pointsCommit)
|
||||
// Apply time-based multiplier to commit score
|
||||
multiplier := getTimeMultiplier(commit.Date.Hour())
|
||||
weekScore[idx] += float64(pointsCommit) * multiplier
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -349,18 +349,6 @@ func TestAggregator_MultipleRepositories(t *testing.T) {
|
||||
assert.Len(t, metrics.Repositories, 2)
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
slice := []string{"a", "b", "c"}
|
||||
|
||||
assert.True(t, contains(slice, "a"))
|
||||
assert.True(t, contains(slice, "b"))
|
||||
assert.True(t, contains(slice, "c"))
|
||||
assert.False(t, contains(slice, "d"))
|
||||
assert.False(t, contains([]string{}, "a"))
|
||||
}
|
||||
|
||||
func TestParseRepoName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ type ContributorMetrics struct {
|
||||
Period Period `json:"period"`
|
||||
|
||||
// Commit metrics
|
||||
CommitCount int `json:"commit_count"`
|
||||
LinesAdded int `json:"lines_added"`
|
||||
LinesDeleted int `json:"lines_deleted"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
CommitCount int `json:"commit_count"`
|
||||
CommitsWithTests int `json:"commits_with_tests"` // Commits that include test files
|
||||
LinesAdded int `json:"lines_added"`
|
||||
LinesDeleted int `json:"lines_deleted"`
|
||||
FilesChanged int `json:"files_changed"`
|
||||
|
||||
// Meaningful line counts (excludes comments and whitespace)
|
||||
MeaningfulLinesAdded int `json:"meaningful_lines_added"`
|
||||
@@ -63,7 +64,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"`
|
||||
@@ -91,7 +99,8 @@ type ScoreBreakdown struct {
|
||||
Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references)
|
||||
ResponseBonus int `json:"response_bonus"`
|
||||
LineChanges int `json:"line_changes"`
|
||||
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
||||
TestsBonus int `json:"tests_bonus"` // Bonus for commits that include test files
|
||||
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
|
||||
}
|
||||
|
||||
// RepositoryMetrics holds aggregated metrics for a single repository
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package scoring
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/lukaszraczylo/git-velocity/internal/config"
|
||||
@@ -23,40 +24,73 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
|
||||
return metrics
|
||||
}
|
||||
|
||||
// Collect all contributor metrics across repositories
|
||||
// Build contributor map for scoring
|
||||
// IMPORTANT: Prefer metrics.Contributors if populated (from aggregator) since it contains
|
||||
// properly calculated values that can't be reconstructed from per-repo data:
|
||||
// - Weighted average times (AvgReviewTime, AvgTimeToMerge)
|
||||
// - Cross-repo streaks (ActiveDays, LongestStreak, WorkWeekStreak)
|
||||
// - Max values (LargestPRSize)
|
||||
// - Deduplicated counts (UniqueReviewees, FilesChanged)
|
||||
// - Summed counts (SmallPRCount, PerfectPRs)
|
||||
// Fall back to aggregating from repos only for tests that don't use the full pipeline.
|
||||
contributorMap := make(map[string]*models.ContributorMetrics)
|
||||
|
||||
for _, repo := range metrics.Repositories {
|
||||
for i := range repo.Contributors {
|
||||
login := repo.Contributors[i].Login
|
||||
if _, ok := contributorMap[login]; !ok {
|
||||
// Copy the contributor metrics
|
||||
cm := repo.Contributors[i]
|
||||
contributorMap[login] = &cm
|
||||
} else {
|
||||
// Aggregate metrics from multiple repos
|
||||
existing := contributorMap[login]
|
||||
cm := repo.Contributors[i]
|
||||
existing.CommitCount += cm.CommitCount
|
||||
existing.LinesAdded += cm.LinesAdded
|
||||
existing.LinesDeleted += cm.LinesDeleted
|
||||
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
|
||||
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
|
||||
existing.CommentLinesAdded += cm.CommentLinesAdded
|
||||
existing.CommentLinesDeleted += cm.CommentLinesDeleted
|
||||
existing.PRsOpened += cm.PRsOpened
|
||||
existing.PRsMerged += cm.PRsMerged
|
||||
existing.ReviewsGiven += cm.ReviewsGiven
|
||||
existing.ReviewComments += cm.ReviewComments
|
||||
// Issue metrics
|
||||
existing.IssuesOpened += cm.IssuesOpened
|
||||
existing.IssuesClosed += cm.IssuesClosed
|
||||
existing.IssueComments += cm.IssueComments
|
||||
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
|
||||
// Combine unique repositories
|
||||
for _, r := range cm.RepositoriesContributed {
|
||||
if !contains(existing.RepositoriesContributed, r) {
|
||||
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
|
||||
if len(metrics.Contributors) > 0 {
|
||||
// Use already-aggregated global contributors (production path)
|
||||
for i := range metrics.Contributors {
|
||||
login := metrics.Contributors[i].Login
|
||||
cm := metrics.Contributors[i]
|
||||
contributorMap[login] = &cm
|
||||
}
|
||||
} else {
|
||||
// Fallback: aggregate from per-repo contributors (test compatibility path)
|
||||
// Note: This path cannot properly aggregate computed fields like AvgReviewTime,
|
||||
// LongestStreak, etc. - it only sums count-based metrics.
|
||||
for _, repo := range metrics.Repositories {
|
||||
for i := range repo.Contributors {
|
||||
login := repo.Contributors[i].Login
|
||||
if _, ok := contributorMap[login]; !ok {
|
||||
// Copy the contributor metrics
|
||||
cm := repo.Contributors[i]
|
||||
contributorMap[login] = &cm
|
||||
} else {
|
||||
// Aggregate metrics from multiple repos
|
||||
existing := contributorMap[login]
|
||||
cm := repo.Contributors[i]
|
||||
existing.CommitCount += cm.CommitCount
|
||||
existing.CommitsWithTests += cm.CommitsWithTests
|
||||
existing.LinesAdded += cm.LinesAdded
|
||||
existing.LinesDeleted += cm.LinesDeleted
|
||||
existing.MeaningfulLinesAdded += cm.MeaningfulLinesAdded
|
||||
existing.MeaningfulLinesDeleted += cm.MeaningfulLinesDeleted
|
||||
existing.CommentLinesAdded += cm.CommentLinesAdded
|
||||
existing.CommentLinesDeleted += cm.CommentLinesDeleted
|
||||
existing.PRsOpened += cm.PRsOpened
|
||||
existing.PRsMerged += cm.PRsMerged
|
||||
existing.ReviewsGiven += cm.ReviewsGiven
|
||||
existing.ReviewComments += cm.ReviewComments
|
||||
// Issue metrics
|
||||
existing.IssuesOpened += cm.IssuesOpened
|
||||
existing.IssuesClosed += cm.IssuesClosed
|
||||
existing.IssueComments += cm.IssueComments
|
||||
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
|
||||
// Activity pattern metrics (for achievements)
|
||||
existing.EarlyBirdCount += cm.EarlyBirdCount
|
||||
existing.NightOwlCount += cm.NightOwlCount
|
||||
existing.MidnightCount += cm.MidnightCount
|
||||
existing.WeekendWarrior += cm.WeekendWarrior
|
||||
existing.OutOfHoursCount += cm.OutOfHoursCount
|
||||
// Time-based commit counts (for multiplier scoring)
|
||||
existing.RegularHoursCount += cm.RegularHoursCount
|
||||
existing.EveningCount += cm.EveningCount
|
||||
existing.LateNightCount += cm.LateNightCount
|
||||
existing.OvernightCount += cm.OvernightCount
|
||||
existing.EarlyMorningCount += cm.EarlyMorningCount
|
||||
// Combine unique repositories
|
||||
for _, r := range cm.RepositoriesContributed {
|
||||
if !slices.Contains(existing.RepositoriesContributed, r) {
|
||||
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,8 +203,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,13 +282,16 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
|
||||
}
|
||||
}
|
||||
|
||||
// Out of hours bonus (commits outside 9am-5pm)
|
||||
// Tests bonus - bonus points for commits that include test files
|
||||
breakdown.TestsBonus = cm.CommitsWithTests * points.CommitWithTests
|
||||
|
||||
// Out of hours bonus (legacy - kept for backwards compatibility but default is 0)
|
||||
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
|
||||
|
||||
// Calculate total
|
||||
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
|
||||
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
|
||||
breakdown.Issues + breakdown.OutOfHours
|
||||
breakdown.Issues + breakdown.TestsBonus + breakdown.OutOfHours
|
||||
|
||||
return models.Score{
|
||||
Total: total,
|
||||
@@ -236,9 +318,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 {
|
||||
@@ -347,12 +431,3 @@ func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics,
|
||||
topAchievers["pull_requests"] = topPRAuthor
|
||||
}
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
@@ -841,18 +843,6 @@ func TestCalculator_WorkWeekStreakAchievement(t *testing.T) {
|
||||
assert.Contains(t, contributor.Achievements, "workweek-5")
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
slice := []string{"a", "b", "c"}
|
||||
|
||||
assert.True(t, contains(slice, "a"))
|
||||
assert.True(t, contains(slice, "b"))
|
||||
assert.True(t, contains(slice, "c"))
|
||||
assert.False(t, contains(slice, "d"))
|
||||
assert.False(t, contains([]string{}, "a"))
|
||||
}
|
||||
|
||||
func TestCalculator_MeaningfulLinesScoring(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
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
+3
-3
@@ -8,11 +8,11 @@
|
||||
<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-IALpeAps.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-DOVyCPqp.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.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">
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+5
-158
@@ -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
|
||||
@@ -301,12 +295,16 @@ func isRetryableError(err error) bool {
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"server error",
|
||||
"stream error",
|
||||
"CANCEL",
|
||||
"EOF",
|
||||
"broken pipe",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
}
|
||||
for _, msg := range retryableMessages {
|
||||
if strings.Contains(strings.ToLower(errStr), msg) {
|
||||
if strings.Contains(strings.ToLower(errStr), strings.ToLower(msg)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -394,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"}
|
||||
|
||||
@@ -735,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
|
||||
}
|
||||
|
||||
+68
-11
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
@@ -133,8 +134,28 @@ func fetchGQLPaginated[Q any, T any, R any](
|
||||
}
|
||||
|
||||
for {
|
||||
if err := client.Query(ctx, config.Query, variables); err != nil {
|
||||
return nil, fmt.Errorf("graphql query failed: %w", err)
|
||||
// Retry logic for transient errors
|
||||
var queryErr error
|
||||
for retries := 0; retries < 3; retries++ {
|
||||
queryErr = client.Query(ctx, config.Query, variables)
|
||||
if queryErr == nil {
|
||||
break
|
||||
}
|
||||
// Check if error is retryable
|
||||
if !isGQLRetryableError(queryErr) {
|
||||
break
|
||||
}
|
||||
// Wait before retry with exponential backoff
|
||||
backoff := time.Duration(1<<retries) * time.Second
|
||||
fmt.Fprintf(os.Stderr, "\r GraphQL retry %d/3 (waiting %s): %v\n", retries+1, backoff, queryErr)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
if queryErr != nil {
|
||||
return nil, fmt.Errorf("graphql query failed: %w", queryErr)
|
||||
}
|
||||
|
||||
page := config.GetPageResult(config.Query)
|
||||
@@ -200,7 +221,7 @@ type gqlPRQuery struct {
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
Nodes []gqlPRNode
|
||||
} `graphql:"pullRequests(first: 100, after: $cursor, states: [MERGED], orderBy: {field: UPDATED_AT, direction: DESC})"`
|
||||
} `graphql:"pullRequests(first: 100, after: $cursor, states: [OPEN, MERGED, CLOSED], orderBy: {field: UPDATED_AT, direction: DESC})"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
@@ -308,23 +329,29 @@ func (g *GraphQLClient) FetchPRsWithReviews(ctx context.Context, owner, repo str
|
||||
}
|
||||
},
|
||||
ProcessNode: func(node gqlPRNode, repoName string) ([]prWithReviews, bool, bool) {
|
||||
// Skip if not merged - not counted as "old"
|
||||
if node.MergedAt == nil {
|
||||
return nil, false, false
|
||||
// Determine the relevant date for filtering:
|
||||
// - For merged PRs: use MergedAt
|
||||
// - For closed PRs: use ClosedAt
|
||||
// - For open PRs: use CreatedAt (they're still active)
|
||||
var relevantDate time.Time
|
||||
if node.MergedAt != nil {
|
||||
relevantDate = *node.MergedAt
|
||||
} else if node.ClosedAt != nil {
|
||||
relevantDate = *node.ClosedAt
|
||||
} else {
|
||||
relevantDate = node.CreatedAt
|
||||
}
|
||||
|
||||
mergedAt := *node.MergedAt
|
||||
|
||||
// Hard cutoff check - stop entirely if past this date
|
||||
if hardCutoff != nil && mergedAt.Before(*hardCutoff) {
|
||||
if hardCutoff != nil && relevantDate.Before(*hardCutoff) {
|
||||
return nil, true, true // Hard stop
|
||||
}
|
||||
|
||||
// Check date range - skip if outside range
|
||||
if until != nil && mergedAt.After(*until) {
|
||||
if until != nil && relevantDate.After(*until) {
|
||||
return nil, false, false // Too new, not "old"
|
||||
}
|
||||
if since != nil && mergedAt.Before(*since) {
|
||||
if since != nil && relevantDate.Before(*since) {
|
||||
return nil, true, false // Too old - signal for early termination tracking
|
||||
}
|
||||
|
||||
@@ -520,3 +547,33 @@ func convertCommentNode(node gqlCommentNode, repoName string, issueNumber int) m
|
||||
CreatedAt: node.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// isGQLRetryableError checks if a GraphQL error is transient and should be retried
|
||||
func isGQLRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
retryablePatterns := []string{
|
||||
"stream error",
|
||||
"cancel",
|
||||
"eof",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"timeout",
|
||||
"temporary failure",
|
||||
"broken pipe",
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
}
|
||||
|
||||
for _, pattern := range retryablePatterns {
|
||||
if strings.Contains(errStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import js from '@eslint/js'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
// Browser globals
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
fetch: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
requestAnimationFrame: 'readonly',
|
||||
cancelAnimationFrame: 'readonly'
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Vue specific rules
|
||||
'vue/multi-word-component-names': 'off', // Allow single-word component names
|
||||
'vue/max-attributes-per-line': 'off', // Allow multiple attributes per line
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/html-self-closing': ['error', {
|
||||
html: { void: 'always', normal: 'never', component: 'always' }
|
||||
}],
|
||||
|
||||
// General JS rules
|
||||
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'no-console': 'warn',
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**']
|
||||
}
|
||||
]
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
<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">
|
||||
</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">
|
||||
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
Generated
+64
@@ -15,6 +15,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
@@ -547,6 +548,22 @@
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.50",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
|
||||
@@ -1829,6 +1846,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
+7
-1
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
@@ -16,8 +18,12 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"vite": "^7.2.7"
|
||||
|
||||
Generated
+2100
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -30,14 +30,14 @@ onMounted(async () => {
|
||||
<div v-if="loading" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
<p class="text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||
<p class="text-gray-400">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -244,17 +244,17 @@ const sizeClasses = {
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
|
||||
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
|
||||
<div class="font-bold text-sm">{{ getAchievement(achievementId).name }}</div>
|
||||
<div class="text-gray-300 text-[11px] mt-0.5">{{ getAchievement(achievementId).description }}</div>
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-900 dark:border-t-gray-800"></div>
|
||||
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Label (optional) - no truncation -->
|
||||
<span
|
||||
v-if="showLabel"
|
||||
class="text-[11px] font-medium text-gray-600 dark:text-gray-400 text-center leading-tight"
|
||||
class="text-[11px] font-medium text-gray-400 text-center leading-tight"
|
||||
style="max-width: 72px; word-wrap: break-word;"
|
||||
>
|
||||
{{ getAchievement(achievementId).name }}
|
||||
|
||||
@@ -234,7 +234,6 @@ const progressItems = computed(() => {
|
||||
|
||||
// Get count of remaining achievements (all unearned across all types)
|
||||
const remainingCount = computed(() => {
|
||||
const earnedSet = new Set(props.contributor.achievements || [])
|
||||
let totalUnearned = 0
|
||||
|
||||
for (const type of achievementTypes) {
|
||||
@@ -255,7 +254,7 @@ const remainingCount = computed(() => {
|
||||
<div
|
||||
v-for="item in progressItems"
|
||||
:key="item.id"
|
||||
class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
class="bg-gray-800/50 rounded-xl p-4 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div class="flex items-center space-x-3">
|
||||
@@ -266,30 +265,30 @@ const remainingCount = computed(() => {
|
||||
<i class="fas text-white text-sm" :class="item.icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-gray-800 dark:text-white">
|
||||
<div class="text-sm font-semibold text-white">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span>{{ item.category }}</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span class="text-gray-400">•</span>
|
||||
<span class="font-medium">Tier {{ item.tierIndex }}/{{ item.totalTiers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-700 dark:text-gray-200'">
|
||||
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-200'">
|
||||
{{ formatNumber(item.currentValue) }}
|
||||
<span class="text-gray-400 dark:text-gray-500 font-normal">/</span>
|
||||
<span class="text-gray-500 dark:text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
|
||||
<span class="text-gray-400 font-normal">/</span>
|
||||
<span class="text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
{{ item.remaining > 0 ? `${formatNumber(item.remaining)} to go` : 'Ready to claim!' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div class="h-2.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="item.progressColor"
|
||||
@@ -304,14 +303,14 @@ const remainingCount = computed(() => {
|
||||
v-for="(t, idx) in item.allTiers.slice(0, 5)"
|
||||
:key="t.threshold"
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
|
||||
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-600'"
|
||||
:title="`Tier ${idx + 1}: ${t.name} (${formatNumber(t.threshold)})`"
|
||||
></span>
|
||||
<span v-if="item.totalTiers > 5" class="text-[10px] text-gray-400">+{{ item.totalTiers - 5 }}</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-semibold"
|
||||
:class="item.isClose ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'"
|
||||
:class="item.isClose ? 'text-green-500' : 'text-gray-400'"
|
||||
>
|
||||
{{ item.progress }}%
|
||||
</span>
|
||||
@@ -319,16 +318,16 @@ const remainingCount = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Show more indicator -->
|
||||
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-500 dark:text-gray-400 pt-2">
|
||||
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-400 pt-2">
|
||||
+{{ remainingCount }} more achievements to unlock
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!progressItems.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<div v-if="!progressItems.length" class="text-center py-8 text-gray-400">
|
||||
<div class="w-16 h-16 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 flex items-center justify-center shadow-lg">
|
||||
<i class="fas fa-trophy text-2xl text-white"></i>
|
||||
</div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-300">All achievements unlocked!</p>
|
||||
<p class="font-medium text-gray-300">All achievements unlocked!</p>
|
||||
<p class="text-sm mt-1">You're a legend!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-400 mb-6">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<RouterLink
|
||||
v-if="item.to"
|
||||
@@ -22,7 +22,7 @@ defineProps({
|
||||
</RouterLink>
|
||||
<span
|
||||
v-else
|
||||
:class="index === items.length - 1 ? 'text-gray-800 dark:text-white' : ''"
|
||||
:class="index === items.length - 1 ? 'text-white' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
padding: { type: Boolean, default: true },
|
||||
hover: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'rounded-xl bg-gray-800 text-white border border-gray-700 shadow',
|
||||
padding ? 'p-6' : '',
|
||||
hover ? 'hover:shadow-lg transition-shadow' : ''
|
||||
]"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import RankBadge from './RankBadge.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
@@ -16,63 +17,74 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'contributor', params: { login: contributor.login } }"
|
||||
:class="[
|
||||
'card animate-fade-in-up block cursor-pointer hover:shadow-lg transition-shadow',
|
||||
featured && rank === 1 ? 'ring-2 ring-yellow-400' : ''
|
||||
]"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
:size="featured ? 'xl' : 'lg'"
|
||||
/>
|
||||
<RankBadge
|
||||
v-if="showRank && rank > 0"
|
||||
:rank="rank"
|
||||
size="sm"
|
||||
class="absolute -top-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="hover:text-primary-500 transition-colors"
|
||||
@click.stop.prevent="window.open(`https://github.com/${contributor.login}`, '_blank')"
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-0.5 opacity-50"></i>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="contributor.team" class="text-xs text-accent-500">{{ contributor.team }}</p>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold gradient-text">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
<Card
|
||||
hover
|
||||
:class="[
|
||||
'animate-[fadeInUp_0.6s_ease-out] h-full',
|
||||
featured && rank === 1 ? 'ring-2 ring-yellow-400' : '',
|
||||
featured && rank === 2 ? 'ring-2 ring-gray-300' : '',
|
||||
featured && rank === 3 ? 'ring-2 ring-amber-600' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header with avatar and rank -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
:size="featured ? 'xl' : 'lg'"
|
||||
class="ring-2 ring-gray-700"
|
||||
/>
|
||||
<RankBadge
|
||||
v-if="showRank && rank > 0"
|
||||
:rank="rank"
|
||||
size="sm"
|
||||
class="absolute -bottom-1 -right-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
@{{ contributor.login }}
|
||||
</p>
|
||||
<p v-if="contributor.team" class="text-xs text-accent-500 mt-0.5">{{ contributor.team }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">points</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="contributor.achievements?.length" class="mt-4 flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements.slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="contributor.achievements.length > 6"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ contributor.achievements.length - 6 }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Score display -->
|
||||
<div class="flex items-center justify-between py-3 px-4 -mx-2 rounded-lg bg-gradient-to-r from-primary-900/20 to-accent-900/20 mb-4">
|
||||
<span class="text-sm font-medium text-gray-300">Score</span>
|
||||
<span class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="mt-auto">
|
||||
<div class="text-xs font-medium text-gray-400 mb-2">Achievements</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in contributor.achievements.slice(0, 8)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="contributor.achievements.length > 8"
|
||||
class="inline-flex items-center justify-center px-2 h-7 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ contributor.achievements.length - 8 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
contributor: {
|
||||
@@ -30,7 +29,7 @@ defineProps({
|
||||
class="ring-2 ring-transparent group-hover:ring-primary-500 transition-all"
|
||||
/>
|
||||
<div>
|
||||
<div class="font-medium text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
|
||||
<div class="font-medium text-white group-hover:text-primary-500 transition-colors">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
@@ -39,13 +38,13 @@ defineProps({
|
||||
:href="`https://github.com/${contributor.login}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-primary-500 transition-colors"
|
||||
class="font-medium text-gray-400 hover:text-primary-500 transition-colors"
|
||||
@click.stop
|
||||
>
|
||||
@{{ contributor.login }}
|
||||
<i class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
|
||||
</a>
|
||||
<span v-else class="text-gray-500 dark:text-gray-400">
|
||||
<span v-else class="font-medium text-gray-400">
|
||||
@{{ contributor.login }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import Card from './Card.vue'
|
||||
|
||||
defineProps({
|
||||
columns: {
|
||||
type: Array,
|
||||
@@ -19,7 +21,7 @@ defineProps({
|
||||
},
|
||||
rowClass: {
|
||||
type: String,
|
||||
default: 'hover:bg-gray-50 dark:hover:bg-gray-800/30 transition'
|
||||
default: 'hover:bg-gray-800/30 transition'
|
||||
},
|
||||
clickableRows: {
|
||||
type: Boolean,
|
||||
@@ -39,15 +41,15 @@ const getAlignClass = (align) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card overflow-hidden p-0">
|
||||
<Card :padding="false" class="overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/50">
|
||||
<thead class="bg-gray-800/50">
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
'px-6 py-4 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider',
|
||||
'px-3 sm:px-6 py-3 sm:py-4 text-xs font-semibold text-gray-400 uppercase tracking-wider',
|
||||
getAlignClass(col.align),
|
||||
col.headerClass
|
||||
]"
|
||||
@@ -56,7 +58,7 @@ const getAlignClass = (align) => {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tr
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id || item.login || index"
|
||||
@@ -66,7 +68,7 @@ const getAlignClass = (align) => {
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="['px-6 py-4', getAlignClass(col.align), col.class]"
|
||||
:class="['px-3 sm:px-6 py-3 sm:py-4', getAlignClass(col.align), col.class]"
|
||||
>
|
||||
<slot :name="col.key" :item="item" :index="index">
|
||||
{{ item[col.key] }}
|
||||
@@ -78,8 +80,8 @@ const getAlignClass = (align) => {
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!items.length" class="text-center py-12">
|
||||
<i :class="emptyIcon" class="text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-500 dark:text-gray-400">{{ emptyMessage }}</p>
|
||||
<i :class="emptyIcon" class="text-4xl text-gray-600 mb-4"></i>
|
||||
<p class="text-gray-400">{{ emptyMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i :class="icon" class="text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<p class="text-gray-400">{{ message }}</p>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,20 +14,20 @@ const generatedAt = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="py-8 px-4 mt-16 border-t border-gray-200 dark:border-gray-700">
|
||||
<footer class="py-8 px-4 mt-16 border-t border-gray-700">
|
||||
<div class="container mx-auto text-center">
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
<p class="text-gray-400">
|
||||
Generated by
|
||||
<a
|
||||
href="https://github.com/lukaszraczylo/git-velocity"
|
||||
class="text-primary-500 hover:text-primary-600"
|
||||
class="text-primary-400 hover:text-primary-300 font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Git Velocity
|
||||
</a>
|
||||
</p>
|
||||
<p v-if="generatedAt" class="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||
<p v-if="generatedAt" class="text-sm text-gray-500 mt-2">
|
||||
{{ generatedAt }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps({
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
|
||||
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
|
||||
<p class="text-gray-400">{{ message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import AchievementBadge from './AchievementBadge.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
@@ -20,60 +21,62 @@ defineProps({
|
||||
<component
|
||||
:is="linkToProfile ? RouterLink : 'div'"
|
||||
:to="linkToProfile ? { name: 'contributor', params: { login: member.login } } : undefined"
|
||||
class="card block"
|
||||
:class="{ 'hover:shadow-lg transition cursor-pointer': linkToProfile }"
|
||||
class="block"
|
||||
:class="{ 'group': linkToProfile }"
|
||||
>
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white">
|
||||
{{ member.name || member.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">@{{ member.login }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center mb-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.commit_count) }}
|
||||
<Card :hover="linkToProfile" :class="{ 'cursor-pointer': linkToProfile }">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-white">
|
||||
{{ member.name || member.login }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-400">@{{ member.login }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.prs_opened) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(member.reviews_given) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score</span>
|
||||
<span class="text-xl font-bold gradient-text">
|
||||
{{ formatNumber(member.score?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-4 text-center mb-4">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.commit_count) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.prs_opened) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(member.reviews_given) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<AchievementBadge
|
||||
v-for="achievement in member.achievements.slice(0, 4)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="member.achievements.length > 4"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ member.achievements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-700">
|
||||
<span class="text-sm text-gray-400">Score</span>
|
||||
<span class="text-xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(member.score?.total || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
|
||||
<AchievementBadge
|
||||
v-for="achievement in member.achievements.slice(0, 4)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="member.achievements.length > 4"
|
||||
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ member.achievements.length - 4 }}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ThemeToggle from './ThemeToggle.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
@@ -11,77 +10,106 @@ const repositories = computed(() => globalData.value?.Repositories || [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-50 glass shadow-modern">
|
||||
<nav class="sticky top-0 z-50 bg-gray-900/80 backdrop-blur-md border-b border-gray-700 shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<RouterLink to="/" class="flex items-center space-x-2">
|
||||
<i class="fas fa-rocket text-2xl gradient-text"></i>
|
||||
<span class="text-xl font-bold gradient-text">Git Velocity</span>
|
||||
<i class="fas fa-rocket text-2xl bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent"></i>
|
||||
<span class="text-xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Git Velocity</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
<RouterLink
|
||||
to="/"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path === '/' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path === '/leaderboard' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/how-scoring-works"
|
||||
:class="route.path === '/how-scoring-works' ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
How Scoring Works
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'text-primary-500 font-medium' : 'text-gray-200 font-medium hover:text-primary-400 transition-colors'"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-700 dark:text-gray-200"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
class="md:hidden p-2 rounded-lg hover:bg-gray-700 transition"
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
>
|
||||
<i class="fas fa-bars text-gray-200"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div v-if="mobileMenuOpen" class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col space-y-3">
|
||||
<div v-if="mobileMenuOpen" class="md:hidden py-2 border-t border-gray-700">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<RouterLink
|
||||
to="/"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Dashboard
|
||||
<i class="fas fa-home mr-3 w-5 text-center"></i>Dashboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/leaderboard"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/leaderboard'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
Leaderboard
|
||||
<i class="fas fa-trophy mr-3 w-5 text-center"></i>Leaderboard
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/how-scoring-works"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path === '/how-scoring-works'
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<i class="fas fa-calculator mr-3 w-5 text-center"></i>How Scoring Works
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-for="repo in repositories"
|
||||
:key="`${repo.Owner}/${repo.Name}`"
|
||||
:to="`/repos/${repo.Owner}/${repo.Name}`"
|
||||
:class="[
|
||||
'block px-4 py-3 rounded-lg text-base font-medium transition-colors',
|
||||
route.path.includes(`/repos/${repo.Owner}/${repo.Name}`)
|
||||
? 'bg-primary-900/20 text-primary-400'
|
||||
: 'text-gray-200 hover:bg-gray-800'
|
||||
]"
|
||||
@click="mobileMenuOpen = false"
|
||||
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
|
||||
>
|
||||
{{ repo.Name }}
|
||||
<i class="fas fa-code-branch mr-3 w-5 text-center"></i>{{ repo.Name }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,11 +38,11 @@ defineProps({
|
||||
<slot name="prefix"></slot>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-3"></i>
|
||||
<span class="gradient-text">{{ title }}</span>
|
||||
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">{{ title }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="subtitle || $slots.subtitle" class="text-gray-600 dark:text-gray-300">
|
||||
<p v-if="subtitle || $slots.subtitle" class="text-gray-300">
|
||||
<slot name="subtitle">{{ subtitle }}</slot>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@ const props = defineProps({
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6 text-xs',
|
||||
md: 'w-8 h-8 text-sm'
|
||||
md: 'w-8 h-8 text-sm',
|
||||
lg: 'w-10 h-10 text-base'
|
||||
}
|
||||
|
||||
const rankClass = computed(() => {
|
||||
if (props.rank === 1) return 'rank-1'
|
||||
if (props.rank === 2) return 'rank-2'
|
||||
if (props.rank === 3) return 'rank-3'
|
||||
return 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
|
||||
if (props.rank === 1) return 'bg-gradient-to-r from-yellow-400 to-amber-500'
|
||||
if (props.rank === 2) return 'bg-gradient-to-r from-slate-400 to-slate-500'
|
||||
if (props.rank === 3) return 'bg-gradient-to-r from-amber-600 to-amber-700'
|
||||
return 'bg-gray-700 text-gray-300'
|
||||
})
|
||||
|
||||
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
|
||||
const isTopThree = computed(() => props.rank >= 1 && props.rank <= 3)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -26,6 +29,7 @@ const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
|
||||
:class="[classes, rankClass, { 'text-white': rank <= 3 }]"
|
||||
class="inline-flex items-center justify-center rounded-full font-bold"
|
||||
>
|
||||
{{ rank }}
|
||||
<i v-if="isTopThree" class="fas fa-trophy"></i>
|
||||
<template v-else>{{ rank }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
@@ -13,35 +14,37 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.owner}/${repo.name}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ repo.name }}
|
||||
</h3>
|
||||
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-white group-hover:text-primary-500 transition">
|
||||
{{ repo.name }}
|
||||
</h3>
|
||||
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_commits) }}
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(repo.total_commits) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ formatNumber(repo.total_prs) }}
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ formatNumber(repo.total_prs) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ repo.active_contributors }}
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ repo.active_contributors }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Contributors</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Contributors</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">
|
||||
<i v-if="icon" :class="[icon, iconColor]" class="mr-2"></i>{{ title }}
|
||||
<slot name="suffix"></slot>
|
||||
</h2>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import Card from './Card.vue'
|
||||
import { formatNumber } from '../composables/formatters'
|
||||
|
||||
defineProps({
|
||||
@@ -6,23 +7,23 @@ defineProps({
|
||||
label: { type: String, required: true },
|
||||
icon: { type: String, default: '' },
|
||||
iconColor: { type: String, default: 'text-gray-500' },
|
||||
valueClass: { type: String, default: 'gradient-text' },
|
||||
valueClass: { type: String, default: 'bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent' },
|
||||
delay: { type: String, default: '0s' }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card animate-fade-in-up" :style="{ animationDelay: delay }">
|
||||
<Card class="animate-[fadeInUp_0.6s_ease-out]" :style="{ animationDelay: delay }">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-3xl font-bold" :class="valueClass">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-xl sm:text-2xl md:text-3xl font-bold truncate" :class="valueClass">
|
||||
{{ typeof value === 'number' ? formatNumber(value) : value }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ label }}</div>
|
||||
<div class="text-xs sm:text-sm text-gray-400 mt-1 truncate">{{ label }}</div>
|
||||
</div>
|
||||
<div v-if="icon" class="text-3xl opacity-50" :class="iconColor">
|
||||
<div v-if="icon" class="text-2xl sm:text-3xl opacity-50 ml-2 flex-shrink-0" :class="iconColor">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from './Card.vue'
|
||||
import Avatar from './Avatar.vue'
|
||||
import { formatNumber, slugify } from '../composables/formatters'
|
||||
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
|
||||
|
||||
defineProps({
|
||||
team: {
|
||||
@@ -14,43 +16,45 @@ defineProps({
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="`/teams/${slugify(team.name)}`"
|
||||
class="card hover:shadow-lg transition group"
|
||||
class="block group"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
></span>
|
||||
</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>
|
||||
<span
|
||||
v-if="(team.members?.length || 0) > 5"
|
||||
class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ team.members.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold gradient-text">
|
||||
{{ formatNumber(team.total_score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Total Score</div>
|
||||
<Card hover>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-white group-hover:text-primary-500 transition">
|
||||
{{ team.name }}
|
||||
</h3>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full"
|
||||
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
|
||||
></span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
{{ team.members?.length || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
|
||||
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<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.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.member_metrics.length - 5 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-lg font-semibold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(team.total_score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Total Score</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-white">
|
||||
{{ team.members?.length || 0 }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Members</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const isDark = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
|
||||
updateTheme()
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
isDark.value = e.matches
|
||||
updateTheme()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(isDark, () => {
|
||||
updateTheme()
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
})
|
||||
|
||||
function updateTheme() {
|
||||
if (isDark.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
@click="toggle"
|
||||
class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
|
||||
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
>
|
||||
<i v-if="isDark" class="fas fa-moon text-purple-400"></i>
|
||||
<i v-else class="fas fa-sun text-yellow-500"></i>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
@@ -49,7 +49,18 @@ const chartData = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
const isMobile = ref(window.innerWidth < 640)
|
||||
|
||||
// Dark mode colors
|
||||
const themeColors = {
|
||||
gridColor: 'rgba(255, 255, 255, 0.1)',
|
||||
textColor: 'rgba(255, 255, 255, 0.7)',
|
||||
tooltipBg: 'rgba(30, 30, 30, 0.95)',
|
||||
tooltipText: '#fff',
|
||||
tooltipBorder: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
@@ -61,20 +72,26 @@ const chartOptions = {
|
||||
position: 'top',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 20,
|
||||
padding: isMobile.value ? 10 : 20,
|
||||
boxWidth: isMobile.value ? 8 : 12,
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 12
|
||||
size: isMobile.value ? 10 : 12
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: 12,
|
||||
backgroundColor: themeColors.tooltipBg,
|
||||
titleColor: themeColors.tooltipText,
|
||||
bodyColor: themeColors.tooltipText,
|
||||
borderColor: themeColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
padding: isMobile.value ? 8 : 12,
|
||||
titleFont: {
|
||||
size: 14
|
||||
size: isMobile.value ? 12 : 14
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13
|
||||
size: isMobile.value ? 11 : 13
|
||||
},
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
@@ -89,19 +106,24 @@ const chartOptions = {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
size: isMobile.value ? 9 : 11
|
||||
},
|
||||
maxRotation: isMobile.value ? 45 : 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: isMobile.value ? 6 : 12
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
color: themeColors.gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: themeColors.textColor,
|
||||
font: {
|
||||
size: 11
|
||||
size: isMobile.value ? 9 : 11
|
||||
},
|
||||
callback: (value) => {
|
||||
if (value >= 1000) {
|
||||
@@ -112,7 +134,7 @@ const chartOptions = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value || !chartData.value.labels.length) return
|
||||
@@ -125,7 +147,7 @@ function createChart() {
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData.value,
|
||||
options: chartOptions
|
||||
options: chartOptions.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,8 +160,24 @@ function updateChart() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
const newIsMobile = window.innerWidth < 640
|
||||
if (newIsMobile !== isMobile.value) {
|
||||
isMobile.value = newIsMobile
|
||||
createChart() // Recreate chart with new options
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
createChart()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.timeline, () => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Application constants
|
||||
*/
|
||||
|
||||
// Default colors
|
||||
export const DEFAULT_TEAM_COLOR = '#8b5cf6' // Purple - matches accent color palette
|
||||
|
||||
// Data paths
|
||||
export const DATA_BASE_PATH = './data'
|
||||
export const GLOBAL_DATA_PATH = `${DATA_BASE_PATH}/global.json`
|
||||
export const CONTRIBUTORS_PATH = `${DATA_BASE_PATH}/contributors`
|
||||
export const REPOS_PATH = `${DATA_BASE_PATH}/repos`
|
||||
@@ -1,13 +1,21 @@
|
||||
// Number formatting thresholds
|
||||
const ONE_MILLION = 1_000_000
|
||||
const ONE_THOUSAND = 1_000
|
||||
|
||||
// Time conversion constants
|
||||
const MINUTES_PER_HOUR = 60
|
||||
const HOURS_PER_DAY = 24
|
||||
|
||||
/**
|
||||
* Format a number with K/M suffixes for large values
|
||||
*/
|
||||
export function formatNumber(n) {
|
||||
if (n === null || n === undefined) return '0'
|
||||
if (n >= 1000000) {
|
||||
return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= ONE_MILLION) {
|
||||
return (n / ONE_MILLION).toFixed(1) + 'M'
|
||||
}
|
||||
if (n >= 1000) {
|
||||
return (n / 1000).toFixed(1) + 'K'
|
||||
if (n >= ONE_THOUSAND) {
|
||||
return (n / ONE_THOUSAND).toFixed(1) + 'K'
|
||||
}
|
||||
return String(n)
|
||||
}
|
||||
@@ -16,14 +24,14 @@ export function formatNumber(n) {
|
||||
* Format hours as a human-readable duration
|
||||
*/
|
||||
export function formatDuration(hours) {
|
||||
if (hours === null || hours === undefined) return '-'
|
||||
if (hours === null || hours === undefined || hours <= 0) return '-'
|
||||
if (hours < 1) {
|
||||
return Math.round(hours * 60) + 'm'
|
||||
return Math.round(hours * MINUTES_PER_HOUR) + 'm'
|
||||
}
|
||||
if (hours < 24) {
|
||||
if (hours < HOURS_PER_DAY) {
|
||||
return hours.toFixed(1) + 'h'
|
||||
}
|
||||
return (hours / 24).toFixed(1) + 'd'
|
||||
return (hours / HOURS_PER_DAY).toFixed(1) + 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,10 +9,12 @@ import Leaderboard from './views/Leaderboard.vue'
|
||||
import Repository from './views/Repository.vue'
|
||||
import Team from './views/Team.vue'
|
||||
import Contributor from './views/Contributor.vue'
|
||||
import HowScoringWorks from './views/HowScoringWorks.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/leaderboard', name: 'leaderboard', component: Leaderboard },
|
||||
{ path: '/how-scoring-works', name: 'how-scoring-works', component: HowScoringWorks },
|
||||
{ path: '/repos/:owner/:name', name: 'repository', component: Repository },
|
||||
{ path: '/teams/:slug', name: 'team', component: Team },
|
||||
{ path: '/contributors/:login', name: 'contributor', component: Contributor },
|
||||
|
||||
@@ -26,123 +26,8 @@
|
||||
--color-accent-800: #6b21a8;
|
||||
--color-accent-900: #581c87;
|
||||
|
||||
--animate-fade-in-up: fadeInUp 0.6s ease-out;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
}
|
||||
|
||||
.dark .glass {
|
||||
background-color: rgb(17 24 39 / 0.7);
|
||||
border-color: rgb(255 255 255 / 0.1);
|
||||
}
|
||||
|
||||
@utility gradient-text {
|
||||
background-image: linear-gradient(to right, var(--color-primary-400), var(--color-accent-400));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@utility shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.dark .shadow-modern {
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
@utility score-card {
|
||||
background-image: linear-gradient(to right, rgb(244 114 182 / 0.1), rgb(192 132 252 / 0.1));
|
||||
border: 1px solid rgb(244 114 182 / 0.2);
|
||||
}
|
||||
|
||||
.dark .score-card {
|
||||
background-image: linear-gradient(to right, rgb(244 114 182 / 0.05), rgb(192 132 252 / 0.05));
|
||||
border-color: rgb(244 114 182 / 0.1);
|
||||
}
|
||||
|
||||
@utility rank-1 {
|
||||
background-image: linear-gradient(to right, #facc15, #f59e0b);
|
||||
}
|
||||
|
||||
@utility rank-2 {
|
||||
background-image: linear-gradient(to right, #94a3b8, #64748b);
|
||||
}
|
||||
|
||||
@utility rank-3 {
|
||||
background-image: linear-gradient(to right, #d97706, #b45309);
|
||||
}
|
||||
|
||||
@utility achievement-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, var(--color-primary-400), var(--color-accent-400));
|
||||
color: white;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
@utility btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-image: linear-gradient(to right, var(--color-primary-500), var(--color-accent-500));
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-image: linear-gradient(to right, var(--color-primary-600), var(--color-accent-600));
|
||||
}
|
||||
|
||||
@utility card {
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
@utility nav-link {
|
||||
color: #374151;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-primary-500);
|
||||
}
|
||||
|
||||
.dark .nav-link {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .nav-link:hover {
|
||||
color: var(--color-primary-400);
|
||||
}
|
||||
|
||||
@utility nav-link-active {
|
||||
color: var(--color-primary-500);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@utility animate-fade-in-up {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, inject } from 'vue'
|
||||
import { useRoute, RouterLink } from 'vue-router'
|
||||
import Card from '../components/Card.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import LoadingState from '../components/LoadingState.vue'
|
||||
import ErrorState from '../components/ErrorState.vue'
|
||||
@@ -78,8 +79,22 @@ async function loadContributor() {
|
||||
}
|
||||
|
||||
onMounted(loadContributor)
|
||||
watch(() => route.params, loadContributor)
|
||||
watch(globalData, loadContributor)
|
||||
|
||||
// Watch for route changes (navigation to different contributor)
|
||||
watch(() => route.params.login, (newLogin, oldLogin) => {
|
||||
if (newLogin && newLogin !== oldLogin) {
|
||||
loadContributor()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for globalData changes, but only reload if we don't have contributor data yet
|
||||
// This prevents double-loading when both route and globalData change on initial navigation
|
||||
watch(globalData, (newData, oldData) => {
|
||||
// Only reload if globalData became available and we have an error or no data
|
||||
if (newData && !oldData && (error.value || !contributor.value)) {
|
||||
loadContributor()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -98,27 +113,27 @@ watch(globalData, loadContributor)
|
||||
:src="contributor.avatar_url"
|
||||
:name="contributor.login"
|
||||
size="2xl"
|
||||
class="shadow-modern"
|
||||
class="shadow-lg"
|
||||
/>
|
||||
|
||||
<div class="text-center md:text-left">
|
||||
<h1 class="text-4xl font-bold gradient-text">
|
||||
<h1 class="text-4xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ contributor.name || contributor.login }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-500 dark:text-gray-400 mt-1">
|
||||
<p class="text-xl text-gray-400 mt-1">
|
||||
<GithubLink :url="`https://github.com/${contributor.login}`">
|
||||
@{{ contributor.login }}
|
||||
</GithubLink>
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-center md:justify-start space-x-4 mt-4">
|
||||
<div class="score-card rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">Score:</span>
|
||||
<span class="text-2xl font-bold gradient-text ml-2">
|
||||
<div class="bg-gradient-to-r from-pink-400/5 to-purple-400/5 border border-pink-400/10 rounded-lg px-4 py-2">
|
||||
<span class="text-sm text-gray-400">Score:</span>
|
||||
<span class="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent ml-2">
|
||||
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.score?.rank" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<div v-if="contributor.score?.rank" class="text-sm text-gray-400">
|
||||
Rank #{{ contributor.score.rank }}
|
||||
<span v-if="contributor.score?.percentile_rank">
|
||||
(Top {{ formatPercent(contributor.score.percentile_rank) }})
|
||||
@@ -177,136 +192,136 @@ watch(globalData, loadContributor)
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Code Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-code text-green-500 mr-2"></i>Code Contributions
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Added</span>
|
||||
<span class="text-gray-300">Lines Added</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
+{{ formatNumber(contributor.lines_added || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Lines Deleted</span>
|
||||
<span class="text-gray-300">Lines Deleted</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
-{{ 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-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-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-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-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">
|
||||
<span class="text-gray-300">Files Changed</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatNumber(contributor.files_changed || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_pr_size" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg PR Size</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
<span class="text-gray-300">Avg PR Size</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatNumber(Math.round(contributor.avg_pr_size)) }} lines
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Review Stats -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-comments text-purple-500 mr-2"></i>Review Activity
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Reviews Given</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
<span class="text-gray-300">Reviews Given</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatNumber(contributor.reviews_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Approvals</span>
|
||||
<span class="text-gray-300">Approvals</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
{{ formatNumber(contributor.approvals_given || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Changes Requested</span>
|
||||
<span class="text-gray-300">Changes Requested</span>
|
||||
<span class="text-orange-500 font-semibold">
|
||||
{{ formatNumber(contributor.changes_requested || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Review Comments</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
<span class="text-gray-300">Review Comments</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatNumber(contributor.review_comments || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="contributor.avg_review_time_hours" class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Avg Review Time</span>
|
||||
<span class="text-gray-800 dark:text-white font-semibold">
|
||||
<span class="text-gray-300">Avg Review Time</span>
|
||||
<span class="text-white font-semibold">
|
||||
{{ formatDuration(contributor.avg_review_time_hours) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Issue Stats -->
|
||||
<div v-if="contributor.issues_opened || contributor.issues_closed || contributor.issue_comments || contributor.issue_references_in_commits" class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<Card v-if="contributor.issues_opened || contributor.issues_closed || contributor.issue_comments || contributor.issue_references_in_commits">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-bug text-red-500 mr-2"></i>Issue Activity
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issues Opened</span>
|
||||
<span class="text-gray-300">Issues Opened</span>
|
||||
<span class="text-red-500 font-semibold">
|
||||
{{ formatNumber(contributor.issues_opened || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issues Closed</span>
|
||||
<span class="text-gray-300">Issues Closed</span>
|
||||
<span class="text-green-500 font-semibold">
|
||||
{{ formatNumber(contributor.issues_closed || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issue Comments</span>
|
||||
<span class="text-gray-300">Issue Comments</span>
|
||||
<span class="text-blue-500 font-semibold">
|
||||
{{ formatNumber(contributor.issue_comments || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-300">Issue References in Commits</span>
|
||||
<span class="text-gray-300">Issue References in Commits</span>
|
||||
<span class="text-purple-500 font-semibold">
|
||||
{{ formatNumber(contributor.issue_references_in_commits || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -314,70 +329,70 @@ watch(globalData, loadContributor)
|
||||
<!-- Score Breakdown -->
|
||||
<section v-if="contributor.score?.breakdown" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
|
||||
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-4">
|
||||
<i class="fas fa-chart-pie bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent mr-2"></i>Score Breakdown
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Commits</div>
|
||||
<div class="text-xs text-gray-400">{{ 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-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">PRs</div>
|
||||
<div class="text-xs text-gray-400">{{ 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-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Reviews</div>
|
||||
<div class="text-xs text-gray-400">{{ 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-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Comments</div>
|
||||
<div class="text-xs text-gray-400">{{ 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-center p-4 rounded-lg bg-gray-800/50">
|
||||
<div class="text-2xl font-bold text-red-500">
|
||||
{{ formatNumber(contributor.score.breakdown.issues || 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Issues</div>
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">opened, closed, comments, refs</div>
|
||||
<div class="text-xs text-gray-400 mt-1">Issues</div>
|
||||
<div class="text-xs text-gray-400">opened, closed, comments, refs</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Line Changes</div>
|
||||
<div class="text-xs text-gray-400">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-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Response Bonus</div>
|
||||
<div class="text-xs text-gray-400">fast review bonus</div>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
|
||||
<div class="text-center p-4 rounded-lg 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 class="text-xs text-gray-400 mt-1">Out of Hours</div>
|
||||
<div class="text-xs text-gray-400">{{ contributor.out_of_hours_count || 0 }} × 2 pts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -386,10 +401,10 @@ watch(globalData, loadContributor)
|
||||
<div class="container mx-auto">
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<!-- Earned Achievements -->
|
||||
<div v-if="contributor.achievements?.length" class="card">
|
||||
<Card v-if="contributor.achievements?.length">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
|
||||
<i class="fas fa-award gradient-text mr-2"></i>Achievements Earned
|
||||
<h3 class="text-lg font-semibold text-white">
|
||||
<i class="fas fa-award bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent mr-2"></i>Achievements Earned
|
||||
</h3>
|
||||
<span class="px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 text-white text-sm font-bold shadow-md">
|
||||
{{ contributor.achievements.length }}
|
||||
@@ -400,7 +415,7 @@ watch(globalData, loadContributor)
|
||||
<div
|
||||
v-for="achievement in contributor.achievements"
|
||||
:key="achievement"
|
||||
class="flex flex-col items-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
class="flex flex-col items-center p-2 rounded-xl bg-gray-800/50 hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<AchievementBadge
|
||||
:achievement-id="achievement"
|
||||
@@ -409,11 +424,11 @@ watch(globalData, loadContributor)
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Progress to Next Achievements -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">
|
||||
<Card>
|
||||
<h3 class="text-lg font-semibold text-white mb-6">
|
||||
<i class="fas fa-chart-line text-primary-500 mr-2"></i>Next Achievements
|
||||
</h3>
|
||||
|
||||
@@ -421,7 +436,7 @@ watch(globalData, loadContributor)
|
||||
:contributor="contributor"
|
||||
:max-display="6"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -440,7 +455,7 @@ watch(globalData, loadContributor)
|
||||
v-for="repo in contributor.repositories_contributed"
|
||||
:key="repo"
|
||||
:to="`/repos/${repo}`"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-800 text-gray-300 hover:bg-primary-900/30 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
<i class="fas fa-code-branch text-gray-400 mr-2"></i>
|
||||
{{ repo }}
|
||||
|
||||
+18
-15
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from '../components/Card.vue'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ContributorCard from '../components/ContributorCard.vue'
|
||||
import RepoCard from '../components/RepoCard.vue'
|
||||
@@ -23,16 +24,16 @@ const showScoreInChart = ref(false)
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<header class="py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-fade-in-up">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">
|
||||
<span class="gradient-text">Git Velocity</span>
|
||||
<header class="py-10 sm:py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-[fadeInUp_0.6s_ease-out]">
|
||||
<h1 class="text-3xl sm:text-4xl md:text-6xl font-bold mb-3 sm:mb-4">
|
||||
<span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Git Velocity</span>
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
<p class="text-base sm:text-xl text-gray-300 max-w-2xl mx-auto px-2">
|
||||
Celebrate your team's achievements and contributions with beautiful insights.
|
||||
</p>
|
||||
<!-- Period and Generation Info -->
|
||||
<div class="flex flex-col items-center space-y-2 mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div class="flex flex-col items-center space-y-2 mt-4 text-sm text-gray-400">
|
||||
<p v-if="metrics.period?.start || metrics.period?.end">
|
||||
<i class="fas fa-calendar-alt mr-1 text-primary-500"></i>
|
||||
<span class="font-medium">Period:</span>
|
||||
@@ -49,22 +50,24 @@ const showScoreInChart = ref(false)
|
||||
</header>
|
||||
|
||||
<!-- Velocity Timeline Chart -->
|
||||
<section v-if="velocityTimeline" class="py-8 px-4">
|
||||
<section v-if="velocityTimeline" class="py-6 sm:py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<Card>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4 sm:mb-6">
|
||||
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<label class="flex items-center space-x-2 text-sm text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showScoreInChart"
|
||||
class="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-600 text-primary-500 focus:ring-primary-500"
|
||||
/>
|
||||
<span>Show Score</span>
|
||||
</label>
|
||||
</div>
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="320px" />
|
||||
</div>
|
||||
<div class="h-[200px] sm:h-[280px] md:h-[320px]">
|
||||
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="100%" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -124,7 +127,7 @@ const showScoreInChart = ref(false)
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<RouterLink to="/leaderboard" class="btn-primary">
|
||||
<RouterLink to="/leaderboard" class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-primary-500 to-accent-500 text-white font-medium rounded-lg shadow-lg hover:from-primary-600 hover:to-accent-600 transition-all">
|
||||
View Full Leaderboard
|
||||
<i class="fas fa-arrow-right ml-2"></i>
|
||||
</RouterLink>
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
<script setup>
|
||||
import Card from '../components/Card.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero Section -->
|
||||
<header class="py-10 sm:py-16 px-4">
|
||||
<div class="container mx-auto text-center animate-[fadeInUp_0.6s_ease-out]">
|
||||
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold mb-3 sm:mb-4 text-white">
|
||||
How <span class="bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">Scoring</span> Works
|
||||
</h1>
|
||||
<p class="text-base sm:text-lg md:text-xl text-gray-300 max-w-2xl mx-auto px-2">
|
||||
Understanding the point system, leaderboard rankings, and achievement criteria that power Git Velocity.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Overview Section -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<Card class="shadow-lg mb-8">
|
||||
<h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
|
||||
<i class="fas fa-info-circle mr-3 text-blue-500"></i>
|
||||
Overview
|
||||
</h2>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Git Velocity calculates developer contributions by analyzing GitHub activity across configured repositories.
|
||||
The scoring system is designed to encourage well-rounded contributions including code commits, pull requests,
|
||||
code reviews, and collaboration.
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-3 gap-4 mt-6">
|
||||
<div class="text-center p-4 bg-primary-900/20 rounded-lg">
|
||||
<i class="fas fa-calculator text-primary-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Point-Based</h3>
|
||||
<p class="text-sm text-gray-400">Activities earn configurable points</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-accent-900/20 rounded-lg">
|
||||
<i class="fas fa-layer-group text-accent-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Aggregated</h3>
|
||||
<p class="text-sm text-gray-400">Combined across all repositories</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-indigo-900/20 rounded-lg">
|
||||
<i class="fas fa-trophy text-indigo-500 text-2xl mb-2"></i>
|
||||
<h3 class="font-medium text-gray-100">Achievement-Driven</h3>
|
||||
<p class="text-sm text-gray-400">Unlock badges for milestones</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Scoring Section -->
|
||||
<section id="scoring" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Point Calculations" icon="fas fa-coins" icon-color="text-yellow-500" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Score Formula -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-lg sm:text-xl">
|
||||
<i class="fas fa-function mr-2 text-primary-500"></i>
|
||||
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 + Tests + Response
|
||||
|
||||
Where:
|
||||
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
|
||||
Tests = commits_with_tests x 15 pts
|
||||
Response = fast review bonus (0-50 pts)
|
||||
|
||||
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>
|
||||
All point values are configurable in your <code class="text-primary-400">.git-velocity.yaml</code> file.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Default Point Values -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-coins mr-2 text-yellow-500"></i>
|
||||
Default Point Values
|
||||
</h3>
|
||||
<!-- Mobile: Card Layout -->
|
||||
<div class="grid grid-cols-1 gap-3 sm:hidden">
|
||||
<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-code-commit text-primary-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Commit</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</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-flask text-green-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Commit + Tests</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">15 pts</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-plus text-blue-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Lines Added</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">0.1 pts</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-minus text-red-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Lines Deleted</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">0.05 pts</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-code-pull-request text-accent-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Opened</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">25 pts</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-code-merge text-indigo-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Merged</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">50 pts</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-eye text-cyan-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">PR Reviewed</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">30 pts</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-comment text-orange-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Review Comment</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</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-bolt text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <1h</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">50 pts</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-stopwatch text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <4h</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">25 pts</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-clock text-yellow-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Fast Review <24h</span>
|
||||
</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-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-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>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Opened</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">10 pts</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-circle-check text-green-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Closed</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">20 pts</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-comment-dots text-blue-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Comment</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</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-link text-accent-500"></i>
|
||||
<span class="text-sm font-medium text-gray-100">Issue Reference</span>
|
||||
</div>
|
||||
<span class="font-mono font-bold text-primary-400">5 pts</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop: Table Layout -->
|
||||
<div class="hidden sm:block overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-3 text-gray-400">Activity</th>
|
||||
<th class="text-left py-3 text-gray-400">Points</th>
|
||||
<th class="text-left py-3 text-gray-400">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-300">
|
||||
<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">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>
|
||||
<td class="py-3 font-mono text-primary-400">15</td>
|
||||
<td class="py-3">Commit that includes test files</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-plus text-blue-500 mr-2"></i>Lines Added</td>
|
||||
<td class="py-3 font-mono text-primary-400">0.1</td>
|
||||
<td class="py-3">Per meaningful line added</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-minus text-red-500 mr-2"></i>Lines Deleted</td>
|
||||
<td class="py-3 font-mono text-primary-400">0.05</td>
|
||||
<td class="py-3">Per meaningful line removed</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-pull-request text-accent-500 mr-2"></i>PR Opened</td>
|
||||
<td class="py-3 font-mono text-primary-400">25</td>
|
||||
<td class="py-3">Per pull request created</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-code-merge text-indigo-500 mr-2"></i>PR Merged</td>
|
||||
<td class="py-3 font-mono text-primary-400">50</td>
|
||||
<td class="py-3">Per pull request merged</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-eye text-cyan-500 mr-2"></i>PR Reviewed</td>
|
||||
<td class="py-3 font-mono text-primary-400">30</td>
|
||||
<td class="py-3">Per PR review submitted</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-comment text-orange-500 mr-2"></i>Review Comment</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per comment on PR reviews</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-bolt text-yellow-500 mr-2"></i>Fast Review (<1h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">50</td>
|
||||
<td class="py-3">Bonus for average response under 1 hour</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-stopwatch text-yellow-500 mr-2"></i>Fast Review (<4h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">25</td>
|
||||
<td class="py-3">Bonus for average response under 4 hours</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-clock text-yellow-500 mr-2"></i>Fast Review (<24h)</td>
|
||||
<td class="py-3 font-mono text-primary-400">10</td>
|
||||
<td class="py-3">Bonus for average response under 24 hours</td>
|
||||
</tr>
|
||||
<!-- 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>
|
||||
<td class="py-3">Per issue created</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-circle-check text-green-500 mr-2"></i>Issue Closed</td>
|
||||
<td class="py-3 font-mono text-primary-400">20</td>
|
||||
<td class="py-3">Per issue resolved/closed</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-3"><i class="fas fa-comment-dots text-blue-500 mr-2"></i>Issue Comment</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per comment on issues</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3"><i class="fas fa-link text-accent-500 mr-2"></i>Issue Reference</td>
|
||||
<td class="py-3 font-mono text-primary-400">5</td>
|
||||
<td class="py-3">Per commit referencing an issue (#123)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Meaningful Lines -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-filter mr-2 text-green-500"></i>
|
||||
Meaningful Lines
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
By default, Git Velocity uses <strong>meaningful lines</strong> instead of raw line counts.
|
||||
This filters out noise and rewards actual code contributions:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-green-900/20 rounded-lg">
|
||||
<h4 class="font-medium text-green-400 mb-2">
|
||||
<i class="fas fa-check mr-2"></i>Counted as Meaningful
|
||||
</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li>Actual code logic</li>
|
||||
<li>Function definitions</li>
|
||||
<li>Variable declarations</li>
|
||||
<li>Import statements</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="p-4 bg-red-900/20 rounded-lg">
|
||||
<h4 class="font-medium text-red-400 mb-2">
|
||||
<i class="fas fa-times mr-2"></i>Filtered Out
|
||||
</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li>Empty lines / whitespace</li>
|
||||
<li>Single-line comments</li>
|
||||
<li>Multi-line comment blocks</li>
|
||||
<li>Documentation strings</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Meaningful lines filtering is always enabled to accurately reflect code contributions.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Leaderboard Section -->
|
||||
<section id="leaderboard-info" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Leaderboard Rankings" icon="fas fa-list-ol" icon-color="text-accent-500" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Ranking Process -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-list-ol mr-2 text-accent-500"></i>
|
||||
Ranking Process
|
||||
</h3>
|
||||
<ol class="space-y-4">
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-900/30 flex items-center justify-center text-primary-400 font-bold">1</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Aggregate Across Repos</h4>
|
||||
<p class="text-sm text-gray-400">Metrics from all configured repositories are combined per contributor</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-accent-900/30 flex items-center justify-center text-accent-400 font-bold">2</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Calculate Total Score</h4>
|
||||
<p class="text-sm text-gray-400">Apply point values to each activity type and sum the breakdown</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-indigo-900/30 flex items-center justify-center text-indigo-400 font-bold">3</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Sort by Score</h4>
|
||||
<p class="text-sm text-gray-400">Contributors are sorted in descending order by total score</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex items-start gap-3">
|
||||
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-blue-900/30 flex items-center justify-center text-blue-400 font-bold">4</span>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-100">Assign Ranks & Percentiles</h4>
|
||||
<p class="text-sm text-gray-400">Each contributor receives a rank (1st, 2nd...) and percentile position</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
|
||||
<!-- Top Categories -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-medal mr-2 text-yellow-500"></i>
|
||||
Top Achievers
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Git Velocity tracks top performers in each category:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-trophy text-yellow-500"></i>
|
||||
<span class="font-medium text-gray-100">Overall Leader</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Highest total score</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-code-commit text-primary-500"></i>
|
||||
<span class="font-medium text-gray-100">Top Committer</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most commits</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-eye text-accent-500"></i>
|
||||
<span class="font-medium text-gray-100">Top Reviewer</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most reviews given</p>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-800 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="fas fa-code-pull-request text-indigo-500"></i>
|
||||
<span class="font-medium text-gray-100">Top PR Author</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">Most PRs opened</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Team Scoring -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-users mr-2 text-blue-500"></i>
|
||||
Team Scoring
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
When teams are configured, Git Velocity calculates team metrics:
|
||||
</p>
|
||||
<ul class="space-y-2 text-gray-400">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Total Team Score:</strong> Sum of all member scores</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Average Score:</strong> Total score / number of members</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i><strong>Member Breakdown:</strong> Individual scores and achievements per team member</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievements Section -->
|
||||
<section id="achievements" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Achievement System" icon="fas fa-trophy" icon-color="text-yellow-500" />
|
||||
<p class="text-gray-300 mb-8 text-center">115 achievements across 26 categories with tiered progression</p>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Achievement Categories -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-trophy mr-2 text-yellow-500"></i>
|
||||
Achievement Categories
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Commits -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-code-commit text-primary-500 mr-2"></i>Commits
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 50, 100, 500, 1000</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Steps, Getting Started, Contributor, Committed, Code Machine, Code Warrior
|
||||
</div>
|
||||
</div>
|
||||
<!-- PRs Opened -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-code-pull-request text-accent-500 mr-2"></i>PRs Opened
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 25, 50, 100, 250</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
PR Pioneer, PR Regular, PR Pro, Merge Master, PR Champion, PR Legend
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reviews -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-eye text-indigo-500 mr-2"></i>Reviews Given
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 10, 25, 50, 100, 250</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Review, Reviewer, Review Regular, Review Expert, Review Guru, Review Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Review Comments -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-comment text-blue-500 mr-2"></i>Review Comments
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 10, 50, 100, 250, 500</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Commentator, Feedback Giver, Code Critic, Feedback Expert, Comment Champion
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lines Added -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-plus text-green-500 mr-2"></i>Lines Added
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 100, 1K, 5K, 10K, 50K</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
First Hundred, Thousand Lines, Five Thousand, Ten Thousand, Code Mountain
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lines Deleted -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-minus text-red-500 mr-2"></i>Lines Deleted
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 100, 500, 1K, 5K, 10K</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Tidying Up, Spring Cleaning, Code Cleaner, Refactoring Hero, Deletion Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Response Time -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-bolt text-yellow-500 mr-2"></i>Response Time
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: <24h, <4h, <1h</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Same Day Reviewer, Quick Responder, Speed Demon
|
||||
</div>
|
||||
</div>
|
||||
<!-- Streaks -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-fire text-orange-500 mr-2"></i>Contribution Streaks
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 3, 7, 14, 30 days</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Getting Rolling, Week Warrior, Two Week Streak, Month Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Activity Patterns -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-clock text-cyan-500 mr-2"></i>Activity Patterns
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Early Bird, Night Owl, Weekend Warrior</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Commits at different times of day unlock special badges
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issues Opened -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-circle-exclamation text-teal-500 mr-2"></i>Issues Opened
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Opener, Reporter, Bug Hunter, Issue Tracker, Issue Master
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issues Closed -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-circle-check text-green-500 mr-2"></i>Issues Closed
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Closer, Problem Solver, Resolver, Issue Crusher, Closure King
|
||||
</div>
|
||||
</div>
|
||||
<!-- Issue Comments -->
|
||||
<div class="p-4 border border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-100 mb-2">
|
||||
<i class="fas fa-comment-dots text-blue-500 mr-2"></i>Issue Comments
|
||||
</h4>
|
||||
<p class="text-xs text-gray-400 mb-2">Tiers: 5, 10, 25, 50, 100</p>
|
||||
<div class="text-xs text-gray-400">
|
||||
Issue Commenter, Discussion Starter, Feedback Provider, Issue Conversationalist, Discussion Champion
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Achievement Conditions -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-unlock mr-2 text-green-500"></i>
|
||||
How Achievements Are Earned
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Each achievement has a <strong>condition type</strong> and <strong>threshold</strong>.
|
||||
When your metrics meet or exceed the threshold, the achievement is unlocked.
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700">
|
||||
<th class="text-left py-2 text-gray-400">Condition Type</th>
|
||||
<th class="text-left py-2 text-gray-400">Metric Checked</th>
|
||||
<th class="text-left py-2 text-gray-400">Comparison</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-gray-300">
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">commit_count</td>
|
||||
<td class="py-2">Total commits</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">pr_opened_count</td>
|
||||
<td class="py-2">PRs opened</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">review_count</td>
|
||||
<td class="py-2">Reviews given</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">avg_review_time_hours</td>
|
||||
<td class="py-2">Average review response</td>
|
||||
<td class="py-2">≤ threshold (lower is better)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">longest_streak</td>
|
||||
<td class="py-2">Consecutive active days</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">perfect_prs</td>
|
||||
<td class="py-2">PRs with no changes requested</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr class="border-b border-gray-800">
|
||||
<td class="py-2 font-mono text-xs">issues_opened</td>
|
||||
<td class="py-2">Issues created</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 font-mono text-xs">issues_closed</td>
|
||||
<td class="py-2">Issues resolved/closed</td>
|
||||
<td class="py-2">≥ threshold</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-shield-halved mr-1"></i>
|
||||
Achievement definitions are hardcoded and cannot be customized to prevent manipulation.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<!-- Tiered Progression -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-layer-group mr-2 text-accent-500"></i>
|
||||
Tiered Progression
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
Most achievements have multiple tiers. As you progress, you unlock higher tiers:
|
||||
</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3 mb-4">
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-gray-800 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gray-400 text-white text-xs sm:text-sm font-bold">1</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-gray-300">Tier 1</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-gray-800 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gray-500 text-white text-xs sm:text-sm font-bold">10</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-gray-300">Tier 2</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-green-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-green-500 text-white text-xs sm:text-sm font-bold">25</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-green-400">Tier 3</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-blue-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-blue-500 text-white text-xs sm:text-sm font-bold">50</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-blue-400">Tier 4</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-purple-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-purple-500 text-white text-xs sm:text-sm font-bold">100</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-purple-400">Tier 5</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-primary-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-primary-500 text-white text-xs sm:text-sm font-bold">250</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-primary-400">Tier 6</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-orange-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-orange-500 text-white text-xs sm:text-sm font-bold">500</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-orange-400">Tier 7</span></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-2 sm:p-3 bg-yellow-900/20 rounded-lg">
|
||||
<span class="w-6 h-6 sm:w-8 sm:h-8 flex items-center justify-center rounded-full bg-gradient-to-r from-yellow-500 to-amber-500 text-white text-xs sm:text-sm font-bold">1k+</span>
|
||||
<div class="text-xs sm:text-sm"><span class="font-medium text-yellow-400">Tier 8+</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
The leaderboard shows only the highest tier achieved per category for each contributor.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Sources Section -->
|
||||
<section id="data-sources" class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Data Sources" icon="fab fa-github" icon-color="text-gray-300" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fab fa-github mr-2 text-gray-300"></i>
|
||||
GitHub API Data
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Commits</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>SHA, message, timestamp</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Author (login, name, email)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Additions, deletions, files changed</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Patch/diff for line analysis</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Pull Requests</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>State (open, merged, closed)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Author and timestamps</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Size (additions, deletions)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Comments count</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">Reviews</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Review state (approved, changes requested)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Reviewer login</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Submission timestamp</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Comment count</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-200 mb-3">User Profiles</h4>
|
||||
<ul class="text-sm text-gray-400 space-y-1">
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>GitHub login (username)</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Display name</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Avatar URL</li>
|
||||
<li><i class="fas fa-check text-green-500 mr-2"></i>Public email (for deduplication)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Calculated Metrics -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-calculator mr-2 text-blue-500"></i>
|
||||
Derived Metrics
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
These metrics are calculated from raw data:
|
||||
</p>
|
||||
<div class="grid sm:grid-cols-2 gap-4 text-sm">
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Meaningful Lines</strong>
|
||||
<p class="text-gray-400">Parsed from commit diffs, filtering comments/whitespace</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Average Review Time</strong>
|
||||
<p class="text-gray-400">Time between PR creation and first review</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Contribution Streaks</strong>
|
||||
<p class="text-gray-400">Consecutive days with activity</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Perfect PRs</strong>
|
||||
<p class="text-gray-400">PRs merged without "changes requested" reviews</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Out of Hours</strong>
|
||||
<p class="text-gray-400">Commits outside 9am-5pm based on commit timestamp</p>
|
||||
</div>
|
||||
<div class="p-3 bg-gray-800 rounded-lg">
|
||||
<strong class="text-gray-100">Issue References</strong>
|
||||
<p class="text-gray-400">Commits containing #123 patterns (fixes, closes, resolves, refs)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Bot Filtering -->
|
||||
<Card class="shadow-lg">
|
||||
<h3 class="font-semibold text-gray-100 mb-4 flex items-center text-xl">
|
||||
<i class="fas fa-robot mr-2 text-red-500"></i>
|
||||
Bot Filtering
|
||||
</h3>
|
||||
<p class="text-gray-400 mb-4">
|
||||
By default, bot activity is excluded from metrics. The following patterns are automatically filtered:
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">*[bot]</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">dependabot*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">renovate*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">github-actions*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">codecov*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">snyk*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">greenkeeper*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">imgbot*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">allcontributors*</code>
|
||||
<code class="px-2 py-1 bg-gray-700 rounded text-sm">semantic-release*</code>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mt-4">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Enable with <code class="text-primary-400">include_bots: true</code> or add custom patterns with <code class="text-primary-400">additional_bot_patterns</code>.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
+153
-68
@@ -1,34 +1,39 @@
|
||||
<script setup>
|
||||
import { inject, computed } from 'vue'
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import Card from '../components/Card.vue'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import DataTable from '../components/DataTable.vue'
|
||||
import ContributorRow from '../components/ContributorRow.vue'
|
||||
import RankBadge from '../components/RankBadge.vue'
|
||||
import Avatar from '../components/Avatar.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 || [])
|
||||
const searchQuery = ref('')
|
||||
|
||||
const allContributors = computed(() => globalData.value?.leaderboard || [])
|
||||
|
||||
const leaderboard = computed(() => {
|
||||
if (!searchQuery.value.trim()) return allContributors.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return allContributors.value.filter(contributor => {
|
||||
const name = (contributor.name || '').toLowerCase()
|
||||
const login = (contributor.login || '').toLowerCase()
|
||||
return name.includes(query) || login.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const tableColumns = [
|
||||
{ key: 'rank', label: 'Rank', align: 'left' },
|
||||
{ key: 'contributor', label: 'Contributor', align: 'left' },
|
||||
{ key: 'achievements', label: 'Achievements', align: 'left' },
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden md:table-cell' },
|
||||
{ key: 'category', label: 'Best At', align: 'left', headerClass: 'hidden sm:table-cell' },
|
||||
{ key: 'achievements', label: 'Achievements', align: 'left', headerClass: 'hidden md:table-cell' },
|
||||
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden xl:table-cell' },
|
||||
{ key: 'score', label: 'Score', align: 'right' }
|
||||
]
|
||||
|
||||
const categoryIcon = (category) => {
|
||||
const icons = {
|
||||
'Commits': 'fas fa-code-commit text-green-500',
|
||||
'PRs': 'fas fa-code-pull-request text-blue-500',
|
||||
'Reviews': 'fas fa-eye text-purple-500',
|
||||
'Comments': 'fas fa-comment text-orange-500'
|
||||
}
|
||||
return icons[category] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -41,64 +46,144 @@ const categoryIcon = (category) => {
|
||||
centered
|
||||
/>
|
||||
|
||||
<!-- Leaderboard Table -->
|
||||
<section class="py-8 px-4">
|
||||
<!-- Search and Leaderboard -->
|
||||
<section class="py-4 sm:py-8 px-4">
|
||||
<div class="container mx-auto max-w-5xl">
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="leaderboard"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #rank="{ item }">
|
||||
<RankBadge :rank="item.rank" />
|
||||
</template>
|
||||
<!-- Search Input -->
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search contributors..."
|
||||
class="w-full pl-10 pr-10 py-2.5 rounded-lg border border-gray-700 bg-gray-800 text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition text-sm sm:text-base"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="searchQuery && leaderboard.length !== allContributors.length" class="mt-2 text-sm text-gray-400">
|
||||
Showing {{ leaderboard.length }} of {{ allContributors.length }} contributors
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" show-github-link />
|
||||
</template>
|
||||
<!-- Mobile Card Layout -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<RouterLink
|
||||
v-for="item in leaderboard"
|
||||
:key="item.login"
|
||||
:to="{ name: 'contributor', params: { login: item.login } }"
|
||||
class="block"
|
||||
>
|
||||
<Card hover class="!p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Rank -->
|
||||
<RankBadge :rank="item.rank" size="sm" />
|
||||
|
||||
<template #achievements="{ item }">
|
||||
<div class="flex flex-wrap gap-1.5 max-w-[280px]">
|
||||
<AchievementBadge
|
||||
v-for="achievement in getHighestTierAchievements(item.achievements)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Avatar -->
|
||||
<Avatar :src="item.avatar_url" :name="item.login" size="md" />
|
||||
|
||||
<template #team="{ item }">
|
||||
<td class="hidden md:table-cell">
|
||||
<span
|
||||
v-if="item.team"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
|
||||
>
|
||||
{{ item.team }}
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-white truncate">
|
||||
{{ item.name || item.login }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 truncate">
|
||||
@{{ item.login }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="text-right">
|
||||
<div class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(item.score) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">pts</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements row -->
|
||||
<div v-if="item.achievements?.length" class="mt-3 pt-3 border-t border-gray-700">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<AchievementBadge
|
||||
v-for="achievement in getHighestTierAchievements(item.achievements).slice(0, 6)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span
|
||||
v-if="getHighestTierAchievements(item.achievements).length > 6"
|
||||
class="inline-flex items-center justify-center px-2 h-7 rounded-lg bg-gray-700 text-gray-300 text-xs font-bold"
|
||||
>
|
||||
+{{ getHighestTierAchievements(item.achievements).length - 6 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!leaderboard.length" class="text-center py-12">
|
||||
<i class="fas fa-users text-4xl text-gray-500 mb-4"></i>
|
||||
<p class="text-gray-400">No contributors found</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Table Layout -->
|
||||
<div class="hidden md:block">
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="leaderboard"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #rank="{ item }">
|
||||
<RankBadge :rank="item.rank" />
|
||||
</template>
|
||||
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" show-github-link />
|
||||
</template>
|
||||
|
||||
<template #achievements="{ item }">
|
||||
<td class="hidden md:table-cell">
|
||||
<div class="flex flex-wrap gap-1.5 max-w-[280px]">
|
||||
<AchievementBadge
|
||||
v-for="achievement in getHighestTierAchievements(item.achievements)"
|
||||
:key="achievement"
|
||||
:achievement-id="achievement"
|
||||
size="sm"
|
||||
/>
|
||||
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #team="{ item }">
|
||||
<td class="hidden xl:table-cell">
|
||||
<span
|
||||
v-if="item.team"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900/30 text-purple-300"
|
||||
>
|
||||
{{ item.team }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(item.score) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #category="{ item }">
|
||||
<td class="hidden sm:table-cell">
|
||||
<span v-if="item.top_category" class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<i :class="categoryIcon(item.top_category)" class="mr-1"></i>
|
||||
{{ item.top_category }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
{{ formatNumber(item.score) }}
|
||||
</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,20 @@ const route = useRoute()
|
||||
const repository = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const allContributors = computed(() => repository.value?.contributors || [])
|
||||
|
||||
const filteredContributors = computed(() => {
|
||||
if (!searchQuery.value.trim()) return allContributors.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
return allContributors.value.filter(contributor => {
|
||||
const name = (contributor.name || '').toLowerCase()
|
||||
const login = (contributor.login || '').toLowerCase()
|
||||
return name.includes(query) || login.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const breadcrumbs = computed(() => [
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
@@ -47,7 +61,13 @@ async function loadRepository() {
|
||||
}
|
||||
|
||||
onMounted(loadRepository)
|
||||
watch(() => route.params, loadRepository)
|
||||
|
||||
// Watch for route changes (navigation to different repository)
|
||||
watch(() => [route.params.owner, route.params.name], ([newOwner, newName], [oldOwner, oldName]) => {
|
||||
if ((newOwner && newName) && (newOwner !== oldOwner || newName !== oldName)) {
|
||||
loadRepository()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,26 +124,50 @@ watch(() => route.params, loadRepository)
|
||||
<!-- Contributors -->
|
||||
<section class="py-8 px-4">
|
||||
<div class="container mx-auto">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" />
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" class="mb-0" />
|
||||
|
||||
<!-- Search Input -->
|
||||
<div class="relative w-full sm:w-72 lg:w-96">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search contributors..."
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-700 bg-gray-800 text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition text-sm"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-200"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="searchQuery && filteredContributors.length !== allContributors.length" class="mb-4 text-sm text-gray-400">
|
||||
Showing {{ filteredContributors.length }} of {{ allContributors.length }} contributors
|
||||
</p>
|
||||
|
||||
<DataTable
|
||||
:columns="tableColumns"
|
||||
:items="repository.contributors"
|
||||
:items="filteredContributors"
|
||||
empty-icon="fas fa-users"
|
||||
empty-message="No contributors found"
|
||||
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
|
||||
row-class="hover:bg-gray-800/30 transition group"
|
||||
>
|
||||
<template #contributor="{ item }">
|
||||
<ContributorRow :contributor="item" />
|
||||
</template>
|
||||
<template #commits="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.commit_count) }}</span>
|
||||
<span class="text-white">{{ formatNumber(item.commit_count) }}</span>
|
||||
</template>
|
||||
<template #prs="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
<span class="text-white">{{ formatNumber(item.prs_opened) }}</span>
|
||||
</template>
|
||||
<template #reviews="{ item }">
|
||||
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
<span class="text-white">{{ formatNumber(item.reviews_given) }}</span>
|
||||
</template>
|
||||
<template #lines="{ item }">
|
||||
<span class="text-green-500">+{{ formatNumber(item.lines_added) }}</span>
|
||||
@@ -131,7 +175,7 @@ watch(() => route.params, loadRepository)
|
||||
<span class="text-red-500">-{{ formatNumber(item.lines_deleted) }}</span>
|
||||
</template>
|
||||
<template #score="{ item }">
|
||||
<span class="text-lg font-bold gradient-text">
|
||||
<span class="text-lg font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
||||
{{ formatNumber(item.score?.total || 0) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
+16
-3
@@ -8,6 +8,7 @@ import StatCard from '../components/StatCard.vue'
|
||||
import MemberCard from '../components/MemberCard.vue'
|
||||
import SectionHeader from '../components/SectionHeader.vue'
|
||||
import { slugify } from '../composables/formatters'
|
||||
import { DEFAULT_TEAM_COLOR } from '../composables/constants'
|
||||
|
||||
const route = useRoute()
|
||||
const globalData = inject('globalData')
|
||||
@@ -38,8 +39,20 @@ function loadTeam() {
|
||||
}
|
||||
|
||||
onMounted(loadTeam)
|
||||
watch(() => route.params, loadTeam)
|
||||
watch(globalData, loadTeam)
|
||||
|
||||
// Watch for route changes (navigation to different team)
|
||||
watch(() => route.params.slug, (newSlug, oldSlug) => {
|
||||
if (newSlug && newSlug !== oldSlug) {
|
||||
loadTeam()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for globalData changes, but only reload if we don't have team data yet
|
||||
watch(globalData, (newData, oldData) => {
|
||||
if (newData && !oldData && (error.value || !team.value)) {
|
||||
loadTeam()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -56,7 +69,7 @@ watch(globalData, loadTeam)
|
||||
<template #prefix>
|
||||
<div
|
||||
class="w-4 h-4 rounded-full mr-4"
|
||||
:style="{ backgroundColor: team.color || '#8b5cf6' }"
|
||||
:style="{ backgroundColor: team.color || DEFAULT_TEAM_COLOR }"
|
||||
></div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
Reference in New Issue
Block a user