Update + signing of the binaries

This commit is contained in:
2025-12-15 00:46:20 +00:00
parent 4aab8af16f
commit 8423b6ada1
23 changed files with 269 additions and 983 deletions
+1
View File
@@ -11,6 +11,7 @@ on:
- main - main
permissions: permissions:
id-token: write
contents: write contents: write
packages: write packages: write
+20
View File
@@ -73,3 +73,23 @@ dockers_v2:
extra_files: extra_files:
- config.example.yaml - 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"
+29 -4
View File
@@ -54,7 +54,7 @@ $ git-velocity serve --port 8080
- **115 Achievements**: Tiered progression from "First Steps" to "Code Warrior" - **115 Achievements**: Tiered progression from "First Steps" to "Code Warrior"
- **Leaderboards**: Compete with your team - **Leaderboards**: Compete with your team
- **Tier Progression**: Multiple tiers per achievement category - **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!) - **Streak Tracking**: Daily streaks and work-week streaks (weekends don't break it!)
- **General velocity chart**: Visualize your velocity over time - **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 - **Bot Filtering**: Hardcoded patterns automatically exclude common bots (Dependabot, Renovate, GitHub Actions, etc.) with optional custom patterns
### 🎨 Beautiful Dashboard ### 🎨 Beautiful Dashboard
- Modern Vue.js SPA with dark/light mode - Modern Vue.js SPA with dark theme
- Responsive design for desktop and mobile - Responsive design for desktop and mobile
- Interactive charts and visualizations - Interactive charts and visualizations
- GitHub Pages deployment ready - 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 # 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 Configuration
Create `.git-velocity.yaml` in your repository: Create `.git-velocity.yaml` in your repository:
@@ -123,13 +142,14 @@ teams:
scoring: scoring:
enabled: true enabled: true
points: points:
commit: 10 commit: 10 # Base points (multiplied by time of day)
commit_with_tests: 15 commit_with_tests: 15
pr_opened: 25 pr_opened: 25
pr_merged: 50 pr_merged: 50
pr_reviewed: 30 pr_reviewed: 30
fast_review_1h: 50 fast_review_1h: 50
fast_review_4h: 25 fast_review_4h: 25
# Time multipliers: x1 (9-5), x2 (5-9pm, 6-9am), x2.5 (9pm-12am), x5 (12-6am)
output: output:
directory: "./dist" directory: "./dist"
@@ -407,7 +427,12 @@ scoring:
fast_review_1h: 50 fast_review_1h: 50
fast_review_4h: 25 fast_review_4h: 25
fast_review_24h: 10 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: output:
directory: "./dist" directory: "./dist"
+33 -4
View File
@@ -158,17 +158,17 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
hour := commit.Date.Hour() hour := commit.Date.Hour()
weekday := commit.Date.Weekday() weekday := commit.Date.Weekday()
// Early bird: commits before 9am // Early bird: commits before 9am (for achievements)
if hour >= 5 && hour < 9 { if hour >= 5 && hour < 9 {
cm.EarlyBirdCount++ cm.EarlyBirdCount++
rcm.EarlyBirdCount++ rcm.EarlyBirdCount++
} }
// Night owl: commits after 9pm // Night owl: commits after 9pm (for achievements)
if hour >= 21 || hour < 5 { if hour >= 21 || hour < 5 {
cm.NightOwlCount++ cm.NightOwlCount++
rcm.NightOwlCount++ rcm.NightOwlCount++
} }
// Nosferatu: commits between midnight and 4am // Nosferatu: commits between midnight and 4am (for achievements)
if hour >= 0 && hour < 4 { if hour >= 0 && hour < 4 {
cm.MidnightCount++ cm.MidnightCount++
rcm.MidnightCount++ rcm.MidnightCount++
@@ -178,12 +178,41 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
cm.WeekendWarrior++ cm.WeekendWarrior++
rcm.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 { if hour < 9 || hour >= 17 {
cm.OutOfHoursCount++ cm.OutOfHoursCount++
rcm.OutOfHoursCount++ rcm.OutOfHoursCount++
} }
// Time-based commit counts for multiplier scoring:
// - 9am-5pm (9-16): Regular hours x1
// - 5pm-9pm (17-20): Evening x2
// - 9pm-midnight (21-23): Late night x2.5
// - midnight-6am (0-5): Overnight x5
// - 6am-9am (6-8): Early morning x2
switch {
case hour >= 9 && hour < 17:
// Regular hours: 9am-5pm (x1)
cm.RegularHoursCount++
rcm.RegularHoursCount++
case hour >= 17 && hour < 21:
// Evening: 5pm-9pm (x2)
cm.EveningCount++
rcm.EveningCount++
case hour >= 21 && hour <= 23:
// Late night: 9pm-midnight (x2.5)
cm.LateNightCount++
rcm.LateNightCount++
case hour >= 0 && hour < 6:
// Overnight: midnight-6am (x5)
cm.OvernightCount++
rcm.OvernightCount++
case hour >= 6 && hour < 9:
// Early morning: 6am-9am (x2)
cm.EarlyMorningCount++
rcm.EarlyMorningCount++
}
// Track activity days (global) // Track activity days (global)
if activityDays[login] == nil { if activityDays[login] == nil {
activityDays[login] = make(map[string]bool) activityDays[login] = make(map[string]bool)
+29 -29
View File
@@ -89,7 +89,14 @@ type PointsConfig struct {
FastReview1h int `yaml:"fast_review_1h"` FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"` FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"` 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 // AchievementConfig defines an achievement badge
@@ -107,18 +114,6 @@ type AchievementCondition struct {
Threshold float64 `yaml:"threshold"` 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 // OutputConfig specifies output generation settings
type OutputConfig struct { type OutputConfig struct {
Directory string `yaml:"directory"` Directory string `yaml:"directory"`
@@ -191,22 +186,27 @@ func DefaultConfig() *Config {
Scoring: ScoringConfig{ Scoring: ScoringConfig{
Enabled: true, Enabled: true,
Points: PointsConfig{ Points: PointsConfig{
Commit: 10, Commit: 10,
CommitWithTests: 15, CommitWithTests: 15,
LinesAdded: 0.1, LinesAdded: 0.1,
LinesDeleted: 0.05, LinesDeleted: 0.05,
PROpened: 25, PROpened: 25,
PRMerged: 50, PRMerged: 50,
PRReviewed: 30, PRReviewed: 30,
ReviewComment: 5, ReviewComment: 5,
IssueOpened: 10, IssueOpened: 10,
IssueClosed: 20, IssueClosed: 20,
IssueComment: 5, IssueComment: 5,
IssueReference: 5, IssueReference: 5,
FastReview1h: 50, FastReview1h: 50,
FastReview4h: 25, FastReview4h: 25,
FastReview24h: 10, FastReview24h: 10,
OutOfHours: 2, 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{ Output: OutputConfig{
-72
View File
@@ -59,78 +59,6 @@ func IsDocumentationFile(filename string) bool {
return false 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) // IsMeaningfulLine checks if a line of code is meaningful (not a comment or whitespace)
func IsMeaningfulLine(line string) bool { func IsMeaningfulLine(line string) bool {
return !IsWhitespaceLine(line) && !IsCommentLine(line) return !IsWhitespaceLine(line) && !IsCommentLine(line)
-262
View File
@@ -144,217 +144,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) { func TestIsMeaningfulLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -378,54 +167,3 @@ 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"
+// ProcessData handles data processing
+// It takes input and returns processed output
func ProcessData(input string) string {
+ // Validate input
+ if input == "" {
+ return ""
+ }
+
+ // Transform the data
+ result := strings.ToUpper(input)
- return input
+ return result
}`
stats := AnalyzePatch(patch)
// Count what's actually in the patch:
// Additions (lines starting with +, not +++):
// 1. +// ProcessData handles data processing -> comment
// 2. +// It takes input and returns processed output -> comment
// 3. + // Validate input -> comment
// 4. + if input == "" -> meaningful
// 5. + return "" -> meaningful
// 6. + } -> meaningful
// 7. + (empty line) -> whitespace
// 8. + // Transform the data -> comment
// 9. + result := strings.ToUpper(input) -> meaningful
// 10. + return result -> meaningful
// Total: 10 additions, 5 meaningful, 4 comments, 1 whitespace
// Deletions (lines starting with -, not ---):
// 1. - return input -> meaningful
// Total: 1 deletion, 1 meaningful
assert.Equal(t, 10, stats.TotalAdditions, "Total additions")
assert.Equal(t, 1, stats.TotalDeletions, "Total deletions")
assert.Equal(t, 5, stats.MeaningfulAdditions, "Meaningful additions")
assert.Equal(t, 1, stats.MeaningfulDeletions, "Meaningful deletions")
assert.Equal(t, 4, stats.CommentAdditions, "Comment additions")
assert.Equal(t, 1, stats.WhitespaceAdditions, "Whitespace additions")
}
-23
View File
@@ -26,26 +26,3 @@ type Commit struct {
// Derived fields // Derived fields
HasTests bool `json:"has_tests"` 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
}
+8 -1
View File
@@ -63,7 +63,14 @@ type ContributorMetrics struct {
NightOwlCount int `json:"night_owl_count"` // Commits after 9pm NightOwlCount int `json:"night_owl_count"` // Commits after 9pm
MidnightCount int `json:"midnight_count"` // Commits between midnight and 4am MidnightCount int `json:"midnight_count"` // Commits between midnight and 4am
WeekendWarrior int `json:"weekend_warrior"` // Weekend commits 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 // Repository participation
RepositoriesContributed []string `json:"repositories_contributed,omitempty"` RepositoriesContributed []string `json:"repositories_contributed,omitempty"`
-75
View File
@@ -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) { func TestPullRequest_IsMerged(t *testing.T) {
t.Parallel() t.Parallel()
+60 -3
View File
@@ -53,6 +53,18 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
existing.IssuesClosed += cm.IssuesClosed existing.IssuesClosed += cm.IssuesClosed
existing.IssueComments += cm.IssueComments existing.IssueComments += cm.IssueComments
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits 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 // Combine unique repositories
for _, r := range cm.RepositoriesContributed { for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) { if !contains(existing.RepositoriesContributed, r) {
@@ -169,8 +181,53 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
points := c.config.Scoring.Points points := c.config.Scoring.Points
breakdown := models.ScoreBreakdown{} breakdown := models.ScoreBreakdown{}
// Commit points // Get multipliers with defaults if not set
breakdown.Commits = cm.CommitCount * points.Commit 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) // Line change points - always use meaningful lines (excluding comments/whitespace)
// to accurately reflect actual code contribution // to accurately reflect actual code contribution
@@ -203,7 +260,7 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
} }
} }
// Out of hours bonus (commits outside 9am-5pm) // Out of hours bonus (legacy - kept for backwards compatibility but default is 0)
breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours breakdown.OutOfHours = cm.OutOfHoursCount * points.OutOfHours
// Calculate total // Calculate total
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,9 +8,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script type="module" crossorigin src="./assets/index-gBkQ2-yN.js"></script> <script type="module" crossorigin src="./assets/index-BERDeI1q.js"></script>
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js"> <link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-CUXA-hqC.css"> <link rel="stylesheet" crossorigin href="./assets/index-Dolyd9gm.css">
</head> </head>
<body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans"> <body class="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 font-sans">
<div id="app"></div> <div id="app"></div>
-48
View File
@@ -110,11 +110,6 @@ type CloneOptions struct {
Depth int 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 // EnsureClonedWithOptions ensures a repository is cloned with specific options
func (r *Repository) EnsureClonedWithOptions(ctx context.Context, owner, name, token string, opts *CloneOptions) error { func (r *Repository) EnsureClonedWithOptions(ctx context.Context, owner, name, token string, opts *CloneOptions) error {
repoPath := r.repoPath(owner, name) repoPath := r.repoPath(owner, name)
@@ -495,46 +490,3 @@ func extractLoginFromEmail(email, fallbackName string) string {
login = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(login, "-") login = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(login, "-")
return 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)
}
-70
View File
@@ -147,76 +147,6 @@ func (c *NoopCache) Clear() error {
return nil 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 // Register types for gob encoding
func init() { func init() {
// Register common types that might be cached // Register common types that might be cached
-88
View File
@@ -149,93 +149,6 @@ func TestFileCache_CreateDirectory(t *testing.T) {
assert.Equal(t, "value", value) 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) { func TestNoopCache_AlwaysReturnsFalse(t *testing.T) {
t.Parallel() t.Parallel()
@@ -285,6 +198,5 @@ func TestCacheInterface(t *testing.T) {
// Ensure all cache types implement the interface // Ensure all cache types implement the interface
var _ Cache = (*FileCache)(nil) var _ Cache = (*FileCache)(nil)
var _ Cache = (*MemoryCache)(nil)
var _ Cache = (*NoopCache)(nil) var _ Cache = (*NoopCache)(nil)
} }
-157
View File
@@ -14,7 +14,6 @@ import (
"github.com/google/go-github/v68/github" "github.com/google/go-github/v68/github"
"github.com/lukaszraczylo/git-velocity/internal/config" "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/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/github/cache" "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 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 // retryWithBackoff executes a function with retry logic
// - For rate limit errors: waits until the limit resets (no retry count limit) // - For rate limit errors: waits until the limit resets (no retry count limit)
// - For network/transient errors: uses exponential backoff with MaxRetries limit // - For network/transient errors: uses exponential backoff with MaxRetries limit
@@ -398,62 +392,6 @@ func (c *Client) GetCommitCountSince(ctx context.Context, owner, repo string, si
return 1, nil 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 // mainBranches are the branches we consider as "main" branches
var mainBranches = []string{"main", "master", "develop", "dev"} var mainBranches = []string{"main", "master", "develop", "dev"}
@@ -739,101 +677,6 @@ func (c *Client) FetchUserProfiles(ctx context.Context, logins []string) (map[st
// Helper functions // 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 { func convertPullRequest(pr *github.PullRequest, owner, repo string) models.PullRequest {
var author models.Author var author models.Author
if pr.User != nil { if pr.User != nil {
-118
View File
@@ -204,121 +204,3 @@ func (f *DateFilteredFetcher[T, R]) ShouldSkip(item T) bool {
} }
return false 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
}
-5
View File
@@ -88,8 +88,3 @@ func (s *Server) CreateHandler() (http.Handler, error) {
func (s *Server) GetAddress() string { func (s *Server) GetAddress() string {
return fmt.Sprintf(":%s", s.port) return fmt.Sprintf(":%s", s.port)
} }
// GetDirectory returns the directory being served
func (s *Server) GetDirectory() string {
return s.directory
}
-7
View File
@@ -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) { func TestServer_ServesJSONWithCorrectContentType(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
+77 -11
View File
@@ -64,17 +64,23 @@ import SectionHeader from '../components/SectionHeader.vue'
Score Formula Score Formula
</h3> </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"> <div class="bg-gray-900 text-gray-100 p-3 sm:p-4 rounded-lg overflow-x-auto mb-4 -mx-2 sm:mx-0">
<pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Bonuses <pre class="text-xs sm:text-sm font-mono whitespace-pre-wrap sm:whitespace-pre"><code>Total Score = Commits + Lines + PRs + Reviews + Comments + Issues + Response
Where: Where:
Commits = commit_count x 10 pts Commits = sum of (commits x 10 x time_multiplier)
Lines = (added x 0.1) + (deleted x 0.05) pts Lines = (added x 0.1) + (deleted x 0.05) pts
PRs = (opened x 25) + (merged x 50) pts PRs = (opened x 25) + (merged x 50) pts
Reviews = reviews_given x 30 pts Reviews = reviews_given x 30 pts
Comments = review_comments x 5 pts Comments = review_comments x 5 pts
Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts Issues = (opened x 10) + (closed x 20) + (comments x 5) + (refs x 5) pts
Response = fast review bonus (0-50 pts) Response = fast review bonus (0-50 pts)
Out of Hrs = commits outside 9-5 x 2 pts</code></pre>
Time Multipliers:
9am - 5pm = x1 (regular hours)
5pm - 9pm = x2 (evening)
9pm - midnight = x2.5 (late night)
midnight - 6am = x5 (overnight)
6am - 9am = x2 (early morning)</code></pre>
</div> </div>
<p class="text-xs sm:text-sm text-gray-400"> <p class="text-xs sm:text-sm text-gray-400">
<i class="fas fa-info-circle mr-1"></i> <i class="fas fa-info-circle mr-1"></i>
@@ -167,13 +173,46 @@ Where:
</div> </div>
<span class="font-mono font-bold text-primary-400">10 pts</span> <span class="font-mono font-bold text-primary-400">10 pts</span>
</div> </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 justify-between p-3 bg-gray-800 rounded-lg">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="fas fa-moon text-gray-400"></i> <i class="fas fa-sun text-yellow-400"></i>
<span class="text-sm font-medium text-gray-100">Out of Hours</span> <span class="text-sm font-medium text-gray-100">9am - 5pm</span>
</div> </div>
<span class="font-mono font-bold text-primary-400">2 pts</span> <span class="font-mono font-bold text-gray-400">x1</span>
</div> </div>
<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 justify-between p-3 bg-gray-800 rounded-lg">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<i class="fas fa-circle-exclamation text-teal-500"></i> <i class="fas fa-circle-exclamation text-teal-500"></i>
@@ -217,7 +256,7 @@ Where:
<tr class="border-b border-gray-800"> <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"><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 font-mono text-primary-400">10</td>
<td class="py-3">Per commit pushed</td> <td class="py-3">Base points per commit (multiplied by time of day)</td>
</tr> </tr>
<tr class="border-b border-gray-800"> <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"><i class="fas fa-flask text-green-500 mr-2"></i>Commit with Tests</td>
@@ -269,11 +308,38 @@ Where:
<td class="py-3 font-mono text-primary-400">10</td> <td class="py-3 font-mono text-primary-400">10</td>
<td class="py-3">Bonus for average response under 24 hours</td> <td class="py-3">Bonus for average response under 24 hours</td>
</tr> </tr>
<tr class="border-b border-gray-800"> <!-- Time Multipliers Section -->
<td class="py-3"><i class="fas fa-moon text-gray-500 mr-2"></i>Out of Hours</td> <tr class="border-b border-gray-700 bg-gray-800/30">
<td class="py-3 font-mono text-primary-400">2</td> <td class="py-3 font-semibold text-gray-200" colspan="3">
<td class="py-3">Per commit outside 9am-5pm</td> <i class="fas fa-clock mr-2 text-primary-400"></i>Time Multipliers (applied to commit points)
</td>
</tr> </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"> <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"><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 font-mono text-primary-400">10</td>