Additional checks on issues.

This commit is contained in:
2025-12-11 19:43:40 +00:00
parent 78f961be81
commit 53b1301404
25 changed files with 1082 additions and 40 deletions
+1
View File
@@ -7,3 +7,4 @@ web/dist/
web/public/data
config.yaml
.claude
public-config.yaml
+19
View File
@@ -0,0 +1,19 @@
run:
timeout: 5m
linters:
enable:
- gosec
- staticcheck
linters-settings:
gosec:
excludes: []
confidence: low
severity: medium
issues:
exclude-dirs:
- .repos
- web
exclude-dirs-use-default: true
+1
View File
@@ -0,0 +1 @@
.repos
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-spa build-quick install clean test test-coverage lint dev dev-spa serve help
.PHONY: all build build-spa build-quick install clean test test-coverage lint security dev dev-spa serve help
# Build configuration
BINARY_NAME := git-velocity
@@ -57,6 +57,11 @@ lint:
@echo "Running linter..."
@golangci-lint run ./...
## Run security scanner (uses .golangci.yml config)
security:
@echo "Running security scanner..."
@golangci-lint run --enable gosec ./...
## Run Vue dev server for frontend development
dev-spa:
@mkdir -p ./dist/data # Ensure data dir exists for symlink
@@ -95,6 +100,7 @@ help:
@echo " test Run tests with race detector"
@echo " test-coverage Run tests with coverage report"
@echo " lint Run golangci-lint"
@echo " security Run gosec security scanner"
@echo " dev-spa Run Vue dev server"
@echo " dev Run analyzer with sample config"
@echo " serve Serve generated output locally"
+13 -3
View File
@@ -51,7 +51,7 @@ $ git-velocity serve --port 8080
### 🎮 Gamification Engine
- **Scoring System**: Earn points for every contribution
- **95 Achievements**: Tiered progression from "First Steps" to "Code Warrior"
- **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
@@ -226,7 +226,7 @@ jobs:
## 🏆 Achievements
Git Velocity includes **95 hardcoded achievements** across 20 categories with multiple progression tiers. Achievements cannot be modified via configuration to prevent manipulation.
Git Velocity includes **115 hardcoded achievements** across 26 categories with multiple progression tiers. Achievements cannot be modified via configuration to prevent manipulation.
### Achievement Categories
@@ -254,6 +254,10 @@ Git Velocity includes **95 hardcoded achievements** across 20 categories with mu
| **Out of Hours** | 10, 25, 50, 100 | Commits outside 9am-5pm |
| **Documentation** | 100, 500, 1K, 2.5K, 5K | Comment/doc lines added |
| **Comment Cleanup** | 50, 200, 500, 1K, 2.5K | Outdated comments removed |
| **Issues Opened** | 1, 5, 10, 25, 50 | Track issues created |
| **Issues Closed** | 1, 5, 10, 25, 50 | Track issues resolved |
| **Issue Comments** | 5, 10, 25, 50, 100 | Track issue discussion participation |
| **Issue References** | 5, 10, 25, 50, 100 | Track commits referencing issues |
### Example Achievements
@@ -270,6 +274,10 @@ Git Velocity includes **95 hardcoded achievements** across 20 categories with mu
| 🏛️ Code Historian | Added 5000 lines of comments/docs |
| ✂️ Comment Trimmer | Removed 50 outdated comment lines |
| 💀 Dead Code Hunter | Removed 500 outdated comment lines |
| 🎫 Issue Opener | Opened your first issue |
| 🏷️ Issue Tracker | Opened 25 issues |
| ✅ Issue Closer | Closed your first issue |
| 🔗 Issue Linker | 25 commits referencing issues |
## 🔑 GitHub Token Permissions
@@ -392,8 +400,10 @@ scoring:
pr_merged: 50
pr_reviewed: 30
review_comment: 5
issue_opened: 15
issue_opened: 10
issue_closed: 20
issue_comment: 5
issue_reference_commit: 5
fast_review_1h: 50
fast_review_4h: 25
fast_review_24h: 10
+89 -4
View File
@@ -181,7 +181,7 @@
Score Formula
</h3>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4">
<pre class="text-sm"><code>Total Score = Commits + Line Changes + PRs + Reviews + Comments + Response Bonus + Out of Hours
<pre class="text-sm"><code>Total Score = Commits + Line Changes + PRs + Reviews + Comments + Issues + Response Bonus + Out of Hours
Where:
Commits = commit_count × 10 points
@@ -189,6 +189,7 @@ Where:
PRs = (PRs_opened × 25) + (PRs_merged × 50) points
Reviews = reviews_given × 30 points
Comments = review_comments × 5 points
Issues = (issues_opened × 10) + (issues_closed × 20) + (issue_comments × 5) + (issue_refs × 5) points
Response = bonus for fast review response (0-50 points)
Out of Hours = commits outside 9am-5pm × 2 points</code></pre>
</div>
@@ -269,11 +270,31 @@ Where:
<td class="py-3 font-mono text-pink-600 dark:text-pink-400">10</td>
<td class="py-3">Bonus for average response under 24 hours</td>
</tr>
<tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-3"><i class="fas fa-moon text-gray-500 mr-2"></i>Out of Hours</td>
<td class="py-3 font-mono text-pink-600 dark:text-pink-400">2</td>
<td class="py-3">Per commit outside 9am-5pm</td>
</tr>
<tr class="border-b border-gray-100 dark: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-pink-600 dark:text-pink-400">10</td>
<td class="py-3">Per issue created</td>
</tr>
<tr class="border-b border-gray-100 dark: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-pink-600 dark:text-pink-400">20</td>
<td class="py-3">Per issue resolved/closed</td>
</tr>
<tr class="border-b border-gray-100 dark: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-pink-600 dark:text-pink-400">5</td>
<td class="py-3">Per comment on issues</td>
</tr>
<tr>
<td class="py-3"><i class="fas fa-link text-purple-500 mr-2"></i>Issue Reference</td>
<td class="py-3 font-mono text-pink-600 dark:text-pink-400">5</td>
<td class="py-3">Per commit referencing an issue (#123)</td>
</tr>
</tbody>
</table>
</div>
@@ -433,7 +454,7 @@ Where:
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Achievement System</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">95 achievements across 22 categories with tiered progression</p>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">115 achievements across 26 categories with tiered progression</p>
</div>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Achievement Categories -->
@@ -533,6 +554,46 @@ Where:
Commits at different times of day unlock special badges
</div>
</div>
<!-- Issues Opened -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-900 dark: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-500 dark:text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
<div class="text-xs text-gray-600 dark:text-gray-400">
Issue Opener → Reporter → Bug Hunter → Issue Tracker → Issue Master
</div>
</div>
<!-- Issues Closed -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-900 dark: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-500 dark:text-gray-400 mb-2">Tiers: 1, 5, 10, 25, 50</p>
<div class="text-xs text-gray-600 dark:text-gray-400">
Issue Closer → Problem Solver → Resolver → Issue Crusher → Closure King
</div>
</div>
<!-- Issue Comments -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-900 dark: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-500 dark:text-gray-400 mb-2">Tiers: 5, 10, 25, 50, 100</p>
<div class="text-xs text-gray-600 dark:text-gray-400">
Issue Commenter → Discussion Starter → Feedback Provider → Issue Conversationalist → Discussion Champion
</div>
</div>
<!-- Issue References -->
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">
<i class="fas fa-link text-purple-500 mr-2"></i>Issue References
</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Tiers: 5, 10, 25, 50, 100</p>
<div class="text-xs text-gray-600 dark:text-gray-400">
Issue Linker → Reference Maker → Connector → Link Master → Reference Champion
</div>
</div>
</div>
</div>
@@ -586,11 +647,31 @@ Where:
<td class="py-2">PRs with no changes requested</td>
<td class="py-2">≥ threshold</td>
</tr>
<tr>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 font-mono text-xs">repo_count</td>
<td class="py-2">Repositories contributed to</td>
<td class="py-2">≥ threshold</td>
</tr>
<tr class="border-b border-gray-100 dark: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 class="border-b border-gray-100 dark:border-gray-800">
<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>
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="py-2 font-mono text-xs">issue_comments</td>
<td class="py-2">Comments on issues</td>
<td class="py-2">≥ threshold</td>
</tr>
<tr>
<td class="py-2 font-mono text-xs">issue_references</td>
<td class="py-2">Commits referencing issues</td>
<td class="py-2">≥ threshold</td>
</tr>
</tbody>
</table>
</div>
@@ -714,6 +795,10 @@ Where:
<strong class="text-gray-900 dark:text-gray-100">Unique Reviewees</strong>
<p class="text-gray-500 dark:text-gray-400">Count of distinct PR authors reviewed</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<strong class="text-gray-900 dark:text-gray-100">Issue References</strong>
<p class="text-gray-500 dark:text-gray-400">Commits containing #123 patterns (fixes, closes, resolves, refs)</p>
</div>
</div>
</div>
+4 -4
View File
@@ -210,7 +210,7 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div>
<div class="text-3xl sm:text-4xl font-bold gradient-text">95</div>
<div class="text-3xl sm:text-4xl font-bold gradient-text">115</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Achievements</div>
</div>
<div>
@@ -255,7 +255,7 @@
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Gamification Engine</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Earn points, unlock 95 achievements, climb leaderboards, progress through tiers</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Earn points, unlock 115 achievements, climb leaderboards, progress through tiers</p>
</div>
</div>
</div>
@@ -415,7 +415,7 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12">
<h2 class="text-2xl sm:text-3xl md:text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Unlock Achievements</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">95 achievements to earn across 22 categories</p>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">115 achievements to earn across 26 categories</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Commit Achievements -->
@@ -509,7 +509,7 @@
</div>
</div>
<div class="text-center mt-8">
<p class="text-gray-600 dark:text-gray-400">...and 87 more achievements to unlock!</p>
<p class="text-gray-600 dark:text-gray-400">...and 107 more achievements to unlock!</p>
</div>
</div>
</section>
+86 -1
View File
@@ -408,6 +408,63 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
}
}
// Process issue comments
for _, comment := range data.IssueComments {
login := comment.Author.Login
if login == "" {
continue
}
// Initialize contributor if needed
if _, ok := contributorMap[login]; !ok {
contributorMap[login] = &models.ContributorMetrics{
Login: login,
Period: period,
}
}
cm := contributorMap[login]
cm.IssueComments++
// Track repository participation
if !contains(cm.RepositoriesContributed, comment.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
}
// Update per-repo contributor metrics
rcm := getRepoContributor(comment.Repository, login, cm.Name, cm.AvatarURL)
rcm.IssueComments++
}
// Count issue references in commits (e.g., "fixes #123", "closes #456", "refs #789")
for _, commit := range data.Commits {
login := commit.Author.Login
if login == "" {
continue
}
// Normalize login
if mappedLogin, ok := emailToLogin[commit.Author.Email]; ok {
login = mappedLogin
}
if mappedLogin, ok := loginToLogin[login]; ok {
login = mappedLogin
}
// Count issue references in commit message
issueRefCount := countIssueReferences(commit.Message)
if issueRefCount > 0 {
if cm, ok := contributorMap[login]; ok {
cm.IssueReferencesInCommits += issueRefCount
}
// Update per-repo contributor metrics
if rcm, ok := repoContributorMap[commit.Repository][login]; ok {
rcm.IssueReferencesInCommits += issueRefCount
}
}
}
// Calculate averages and finalize contributor metrics
for login, cm := range contributorMap {
// Calculate average time to merge
@@ -1272,7 +1329,6 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
// Calculate streaks
longest = 1
current = 1
streak := 1
for i := 1; i < len(dates); i++ {
@@ -1300,3 +1356,32 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
return longest, current
}
// countIssueReferences counts the number of issue references in a commit message
// Detects patterns like: fixes #123, closes #456, resolves #789, refs #12, etc.
func countIssueReferences(message string) int {
count := 0
// Count all #<number> patterns in the message
// This covers both keyword-prefixed references (fixes #123, closes #456)
// and standalone mentions (see #123, just #123)
// We only count each unique position once
for i := 0; i < len(message); i++ {
if message[i] == '#' && i+1 < len(message) {
// Check for digits after #
hasDigits := false
for j := i + 1; j < len(message); j++ {
if message[j] >= '0' && message[j] <= '9' {
hasDigits = true
} else {
break
}
}
if hasDigits {
count++
}
}
}
return count
}
+245
View File
@@ -876,3 +876,248 @@ func TestBuildEmailToLoginMapping_NoReplyEmailWithoutID(t *testing.T) {
// Should map via name matching since there's a PR author with the same name
assert.Equal(t, "johndoe", mapping["johndoe@users.noreply.github.com"])
}
func TestCountIssueReferences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
expected int
}{
{
name: "no references",
message: "Just a regular commit message",
expected: 0,
},
{
name: "fixes issue",
message: "fixes #123",
expected: 1,
},
{
name: "Fixes issue uppercase",
message: "Fixes #456",
expected: 1,
},
{
name: "closes issue",
message: "closes #789",
expected: 1,
},
{
name: "resolves issue",
message: "resolves #101",
expected: 1,
},
{
name: "refs issue",
message: "refs #202",
expected: 1,
},
{
name: "ref issue",
message: "ref #303",
expected: 1,
},
{
name: "multiple fixes",
message: "fixes #1, fixes #2, fixes #3",
expected: 3,
},
{
name: "mixed keywords",
message: "fixes #1 and closes #2",
expected: 2,
},
{
name: "standalone issue reference",
message: "Related to #123",
expected: 1,
},
{
name: "multiple standalone references",
message: "See #1 and #2 for context",
expected: 2,
},
{
name: "fix with extra whitespace",
message: "fix #123",
expected: 1,
},
{
name: "closed past tense",
message: "closed #123",
expected: 1,
},
{
name: "fixed past tense",
message: "fixed #456",
expected: 1,
},
{
name: "resolved past tense",
message: "resolved #789",
expected: 1,
},
{
name: "close without s",
message: "close #123",
expected: 1,
},
{
name: "fix without es",
message: "fix #456",
expected: 1,
},
{
name: "resolve without s",
message: "resolve #789",
expected: 1,
},
{
name: "hash without number",
message: "This is about # something",
expected: 0,
},
{
name: "complex commit message",
message: "feat: Add new feature\n\nThis implements the feature requested in #123.\nAlso fixes #456 and closes #789.",
expected: 3,
},
{
name: "PR style reference",
message: "Merge pull request #100 from feature-branch",
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := countIssueReferences(tt.message)
assert.Equal(t, tt.expected, result, "message: %s", tt.message)
})
}
}
func TestAggregator_IssueComments(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
// Need a commit to create the repository
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
},
IssueComments: []models.IssueComment{
{
ID: 1,
Issue: 1,
Repository: "owner/repo",
Author: models.Author{Login: "user1"},
CreatedAt: time.Now(),
},
{
ID: 2,
Issue: 1,
Repository: "owner/repo",
Author: models.Author{Login: "user1"},
CreatedAt: time.Now(),
},
{
ID: 3,
Issue: 2,
Repository: "owner/repo",
Author: models.Author{Login: "user2"},
CreatedAt: time.Now(),
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
// Check that issue comments are counted
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
// Find user1 and user2
var user1, user2 *models.ContributorMetrics
for i := range repo.Contributors {
if repo.Contributors[i].Login == "user1" {
user1 = &repo.Contributors[i]
}
if repo.Contributors[i].Login == "user2" {
user2 = &repo.Contributors[i]
}
}
require.NotNil(t, user1)
assert.Equal(t, 2, user1.IssueComments) // user1 has 2 comments
require.NotNil(t, user2)
assert.Equal(t, 1, user2.IssueComments) // user2 has 1 comment
}
func TestAggregator_IssueReferencesInCommits(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Message: "fixes #1 and closes #2",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
{
SHA: "def456",
Message: "Regular commit without issue refs",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
{
SHA: "ghi789",
Message: "resolves #3",
Author: models.Author{Login: "user2"},
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
// Find user1 and user2
var user1, user2 *models.ContributorMetrics
for i := range repo.Contributors {
if repo.Contributors[i].Login == "user1" {
user1 = &repo.Contributors[i]
}
if repo.Contributors[i].Login == "user2" {
user2 = &repo.Contributors[i]
}
}
require.NotNil(t, user1)
assert.Equal(t, 2, user1.IssueReferencesInCommits) // user1 has 2 issue references (fixes #1, closes #2)
require.NotNil(t, user2)
assert.Equal(t, 1, user2.IssueReferencesInCommits) // user2 has 1 issue reference (resolves #3)
}
+13
View File
@@ -267,6 +267,19 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
}
}
// Fetch issue comments
issueComments, err := a.client.FetchIssueComments(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch issue comments: %w", err)
}
a.log(" Found %d issue comments", len(issueComments))
for _, comment := range issueComments {
if !a.config.IsBot(comment.Author.Login) {
data.IssueComments = append(data.IssueComments, comment)
}
}
return nil
}
+2 -2
View File
@@ -193,7 +193,7 @@ date_range:
// Create temp config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.configYAML), 0644)
err := os.WriteFile(configPath, []byte(tt.configYAML), 0600)
require.NoError(t, err)
// Load config
@@ -940,7 +940,7 @@ func TestLoad_FileNotFound(t *testing.T) {
func TestLoad_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0644)
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0600)
require.NoError(t, err)
_, err = Load(configPath)
+33 -1
View File
@@ -84,6 +84,8 @@ type PointsConfig struct {
ReviewComment int `yaml:"review_comment"` // PR review comments (not code comments)
IssueOpened int `yaml:"issue_opened"`
IssueClosed int `yaml:"issue_closed"`
IssueComment int `yaml:"issue_comment"` // Commenting on an issue
IssueReference int `yaml:"issue_reference_commit"` // Commit referencing an issue (fixes #123, etc.)
FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"`
@@ -197,8 +199,10 @@ func DefaultConfig() *Config {
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
@@ -371,5 +375,33 @@ func defaultAchievements() []AchievementConfig {
{ID: "docs-del-500", Name: "Dead Code Hunter", Description: "Removed 500 lines of outdated comments", Icon: "fa-skull-crossbones", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 500}},
{ID: "docs-del-1000", Name: "Comment Surgeon", Description: "Removed 1000 lines of outdated comments", Icon: "fa-scalpel", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 1000}},
{ID: "docs-del-2500", Name: "Noise Eliminator", Description: "Removed 2500 lines of outdated comments", Icon: "fa-volume-xmark", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 2500}},
// ===== ISSUES OPENED (Tiers: 1, 5, 10, 25, 50) =====
{ID: "issue-1", Name: "Bug Hunter", Description: "Opened your first issue", Icon: "fa-bug", Condition: AchievementCondition{Type: "issues_opened", Threshold: 1}},
{ID: "issue-5", Name: "Issue Reporter", Description: "Opened 5 issues", Icon: "fa-flag", Condition: AchievementCondition{Type: "issues_opened", Threshold: 5}},
{ID: "issue-10", Name: "Quality Advocate", Description: "Opened 10 issues", Icon: "fa-clipboard-list", Condition: AchievementCondition{Type: "issues_opened", Threshold: 10}},
{ID: "issue-25", Name: "Issue Expert", Description: "Opened 25 issues", Icon: "fa-list-check", Condition: AchievementCondition{Type: "issues_opened", Threshold: 25}},
{ID: "issue-50", Name: "Issue Champion", Description: "Opened 50 issues", Icon: "fa-bullhorn", Condition: AchievementCondition{Type: "issues_opened", Threshold: 50}},
// ===== ISSUES CLOSED (Tiers: 1, 5, 10, 25, 50) =====
{ID: "issue-close-1", Name: "Problem Solver", Description: "Closed your first issue", Icon: "fa-circle-check", Condition: AchievementCondition{Type: "issues_closed", Threshold: 1}},
{ID: "issue-close-5", Name: "Bug Squasher", Description: "Closed 5 issues", Icon: "fa-bug-slash", Condition: AchievementCondition{Type: "issues_closed", Threshold: 5}},
{ID: "issue-close-10", Name: "Issue Resolver", Description: "Closed 10 issues", Icon: "fa-check-double", Condition: AchievementCondition{Type: "issues_closed", Threshold: 10}},
{ID: "issue-close-25", Name: "Closure Expert", Description: "Closed 25 issues", Icon: "fa-square-check", Condition: AchievementCondition{Type: "issues_closed", Threshold: 25}},
{ID: "issue-close-50", Name: "Issue Terminator", Description: "Closed 50 issues", Icon: "fa-crosshairs", Condition: AchievementCondition{Type: "issues_closed", Threshold: 50}},
// ===== ISSUE COMMENTS (Tiers: 5, 10, 25, 50, 100) =====
{ID: "issue-comment-5", Name: "Issue Commenter", Description: "Left 5 issue comments", Icon: "fa-comment", Condition: AchievementCondition{Type: "issue_comments", Threshold: 5}},
{ID: "issue-comment-10", Name: "Discussion Starter", Description: "Left 10 issue comments", Icon: "fa-comments", Condition: AchievementCondition{Type: "issue_comments", Threshold: 10}},
{ID: "issue-comment-25", Name: "Issue Collaborator", Description: "Left 25 issue comments", Icon: "fa-people-arrows", Condition: AchievementCondition{Type: "issue_comments", Threshold: 25}},
{ID: "issue-comment-50", Name: "Community Voice", Description: "Left 50 issue comments", Icon: "fa-bullhorn", Condition: AchievementCondition{Type: "issue_comments", Threshold: 50}},
{ID: "issue-comment-100", Name: "Issue Guru", Description: "Left 100 issue comments", Icon: "fa-graduation-cap", Condition: AchievementCondition{Type: "issue_comments", Threshold: 100}},
// ===== ISSUE REFERENCES IN COMMITS (Tiers: 5, 10, 25, 50, 100) =====
{ID: "issue-ref-5", Name: "Issue Linker", Description: "Referenced issues in 5 commits", Icon: "fa-link", Condition: AchievementCondition{Type: "issue_references", Threshold: 5}},
{ID: "issue-ref-10", Name: "Commit Connector", Description: "Referenced issues in 10 commits", Icon: "fa-diagram-project", Condition: AchievementCondition{Type: "issue_references", Threshold: 10}},
{ID: "issue-ref-25", Name: "Traceability Pro", Description: "Referenced issues in 25 commits", Icon: "fa-sitemap", Condition: AchievementCondition{Type: "issue_references", Threshold: 25}},
{ID: "issue-ref-50", Name: "Issue Tracker", Description: "Referenced issues in 50 commits", Icon: "fa-chart-gantt", Condition: AchievementCondition{Type: "issue_references", Threshold: 50}},
{ID: "issue-ref-100", Name: "Traceability Master", Description: "Referenced issues in 100 commits", Icon: "fa-network-wired", Condition: AchievementCondition{Type: "issue_references", Threshold: 100}},
}
}
+5 -3
View File
@@ -49,9 +49,10 @@ type ContributorMetrics struct {
AvgReviewTime float64 `json:"avg_review_time_hours"`
// Issue metrics
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
IssueReferencesInCommits int `json:"issue_references_in_commits"` // Commits referencing issues (fixes #123, etc.)
// Activity patterns
ActiveDays int `json:"active_days"` // Unique days with activity
@@ -87,6 +88,7 @@ type ScoreBreakdown struct {
PRs int `json:"prs"`
Reviews int `json:"reviews"`
Comments int `json:"comments"` // PR review comments (not code comments)
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
+5 -4
View File
@@ -2,8 +2,9 @@ package models
// RawData holds the raw collected data from GitHub
type RawData struct {
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
IssueComments []IssueComment
}
+22 -1
View File
@@ -48,6 +48,11 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
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) {
@@ -181,6 +186,12 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Comment points (PR review comments)
breakdown.Comments = cm.ReviewComments * points.ReviewComment
// Issue points
breakdown.Issues = cm.IssuesOpened*points.IssueOpened +
cm.IssuesClosed*points.IssueClosed +
cm.IssueComments*points.IssueComment +
cm.IssueReferencesInCommits*points.IssueReference
// Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
if cm.AvgReviewTime <= 1 {
@@ -197,7 +208,8 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments + breakdown.OutOfHours
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
breakdown.Issues + breakdown.OutOfHours
return models.Score{
Total: total,
@@ -265,6 +277,15 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
earned = float64(cm.CommentLinesAdded) >= ach.Condition.Threshold
case "comment_lines_deleted":
earned = float64(cm.CommentLinesDeleted) >= ach.Condition.Threshold
// Issue metrics
case "issues_opened":
earned = float64(cm.IssuesOpened) >= ach.Condition.Threshold
case "issues_closed":
earned = float64(cm.IssuesClosed) >= ach.Condition.Threshold
case "issue_comments":
earned = float64(cm.IssueComments) >= ach.Condition.Threshold
case "issue_references":
earned = float64(cm.IssueReferencesInCommits) >= ach.Condition.Threshold
}
if earned {
+316
View File
@@ -1105,3 +1105,319 @@ func TestCalculator_CommentLinesAchievements(t *testing.T) {
assert.NotContains(t, entry.Achievements, "docs-del-200", "60 < 200")
})
}
func TestCalculator_IssueScoring(t *testing.T) {
t.Parallel()
t.Run("calculates issue points correctly", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
IssueOpened: 10, // 10 points per issue opened
IssueClosed: 20, // 20 points per issue closed
IssueComment: 5, // 5 points per issue comment
IssueReference: 5, // 5 points per issue reference in commit
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
CommitCount: 10,
IssuesOpened: 5, // 5 * 10 = 50
IssuesClosed: 3, // 3 * 20 = 60
IssueComments: 10, // 10 * 5 = 50
IssueReferencesInCommits: 8, // 8 * 5 = 40
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Issue points: 50 + 60 + 50 + 40 = 200
assert.Equal(t, 200, contributor.Score.Breakdown.Issues)
// Commits: 10 * 10 = 100
// Total: 100 + 200 = 300
assert.Equal(t, 300, contributor.Score.Total)
})
t.Run("aggregates issue metrics across repositories", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
IssuesOpened: 3,
IssuesClosed: 2,
IssueComments: 5,
IssueReferencesInCommits: 4,
RepositoriesContributed: []string{"owner/repo1"},
},
},
},
{
FullName: "owner/repo2",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
IssuesOpened: 2,
IssuesClosed: 1,
IssueComments: 3,
IssueReferencesInCommits: 2,
RepositoriesContributed: []string{"owner/repo2"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
// Aggregated: 5 opened, 3 closed, 8 comments, 6 references
// Points: 5*10 + 3*20 + 8*5 + 6*5 = 50 + 60 + 40 + 30 = 180
assert.Equal(t, 180, result.Leaderboard[0].Score)
})
t.Run("zero issue metrics results in zero issue points", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "code-only",
CommitCount: 20,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, 0, contributor.Score.Breakdown.Issues)
// Only commits: 20 * 10 = 200
assert.Equal(t, 200, contributor.Score.Total)
})
}
func TestCalculator_IssueAchievements(t *testing.T) {
t.Parallel()
t.Run("earns issue opened achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "bug-hunter",
IssuesOpened: 12, // Should earn issue-1, issue-5, issue-10
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-1", "Should earn issue-1 for 1+ issues opened")
assert.Contains(t, contributor.Achievements, "issue-5", "Should earn issue-5 for 5+ issues opened")
assert.Contains(t, contributor.Achievements, "issue-10", "Should earn issue-10 for 10+ issues opened")
assert.NotContains(t, contributor.Achievements, "issue-25", "Should not earn issue-25 for <25 issues")
})
t.Run("earns issue closed achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "problem-solver",
IssuesClosed: 8, // Should earn issue-close-1, issue-close-5
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-close-1", "Should earn issue-close-1 for 1+ issues closed")
assert.Contains(t, contributor.Achievements, "issue-close-5", "Should earn issue-close-5 for 5+ issues closed")
assert.NotContains(t, contributor.Achievements, "issue-close-10", "Should not earn issue-close-10 for <10 issues")
})
t.Run("earns issue comment achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "discusser",
IssueComments: 30, // Should earn issue-comment-5, issue-comment-10, issue-comment-25
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-comment-5", "Should earn issue-comment-5 for 5+ comments")
assert.Contains(t, contributor.Achievements, "issue-comment-10", "Should earn issue-comment-10 for 10+ comments")
assert.Contains(t, contributor.Achievements, "issue-comment-25", "Should earn issue-comment-25 for 25+ comments")
assert.NotContains(t, contributor.Achievements, "issue-comment-50", "Should not earn issue-comment-50 for <50 comments")
})
t.Run("earns issue reference achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "linker",
IssueReferencesInCommits: 15, // Should earn issue-ref-5, issue-ref-10
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-ref-5", "Should earn issue-ref-5 for 5+ references")
assert.Contains(t, contributor.Achievements, "issue-ref-10", "Should earn issue-ref-10 for 10+ references")
assert.NotContains(t, contributor.Achievements, "issue-ref-25", "Should not earn issue-ref-25 for <25 references")
})
t.Run("earns all issue achievement tiers", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "super-issue-worker",
IssuesOpened: 100,
IssuesClosed: 100,
IssueComments: 150,
IssueReferencesInCommits: 150,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have all issue opened achievements
assert.Contains(t, contributor.Achievements, "issue-1")
assert.Contains(t, contributor.Achievements, "issue-5")
assert.Contains(t, contributor.Achievements, "issue-10")
assert.Contains(t, contributor.Achievements, "issue-25")
assert.Contains(t, contributor.Achievements, "issue-50")
// Should have all issue closed achievements
assert.Contains(t, contributor.Achievements, "issue-close-1")
assert.Contains(t, contributor.Achievements, "issue-close-5")
assert.Contains(t, contributor.Achievements, "issue-close-10")
assert.Contains(t, contributor.Achievements, "issue-close-25")
assert.Contains(t, contributor.Achievements, "issue-close-50")
// Should have all issue comment achievements
assert.Contains(t, contributor.Achievements, "issue-comment-5")
assert.Contains(t, contributor.Achievements, "issue-comment-10")
assert.Contains(t, contributor.Achievements, "issue-comment-25")
assert.Contains(t, contributor.Achievements, "issue-comment-50")
assert.Contains(t, contributor.Achievements, "issue-comment-100")
// Should have all issue reference achievements
assert.Contains(t, contributor.Achievements, "issue-ref-5")
assert.Contains(t, contributor.Achievements, "issue-ref-10")
assert.Contains(t, contributor.Achievements, "issue-ref-25")
assert.Contains(t, contributor.Achievements, "issue-ref-50")
assert.Contains(t, contributor.Achievements, "issue-ref-100")
})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,9 +8,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script type="module" crossorigin src="./assets/index-LBN7XWrH.js"></script>
<script type="module" crossorigin src="./assets/index-IALpeAps.js"></script>
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-8XjWwD9J.css">
<link rel="stylesheet" crossorigin href="./assets/index-DOVyCPqp.css">
</head>
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
<div id="app"></div>
+124
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -611,6 +612,129 @@ func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, unt
return allIssues, nil
}
// FetchIssueComments fetches comments on issues from a repository
// Uses early termination when sorted by date - stops when items are outside date range
func (c *Client) FetchIssueComments(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.IssueComment, error) {
cacheKey := fmt.Sprintf("issue_comments:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if comments, ok := cached.([]models.IssueComment); ok {
c.progress(" Using cached issue comments data")
return comments, nil
}
}
var allComments []models.IssueComment
// Sort by created date descending - newest first
// This allows us to stop early when we hit items older than our date range
opts := &github.IssueListCommentsOptions{
Sort: github.Ptr("created"),
Direction: github.Ptr("desc"),
ListOptions: github.ListOptions{
PerPage: 100,
},
}
// Set 'since' parameter if provided (GitHub filters by update time but we'll also filter manually)
if since != nil {
opts.Since = since
}
page := 1
reachedOldItems := false
for {
var comments []*github.IssueComment
var resp *github.Response
err := c.retryWithBackoff(ctx, "list issue comments", func() error {
var err error
// Passing empty issue number fetches all comments in the repo
comments, resp, err = c.gh.Issues.ListComments(ctx, owner, repo, 0, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list issue comments: %w", err)
}
c.progress(fmt.Sprintf(" Fetching issue comments page %d (%d comments so far)...", page, len(allComments)))
oldItemsInPage := 0
totalItems := len(comments)
for _, comment := range comments {
createdAt := comment.GetCreatedAt().Time
// Skip items newer than our range (when until is specified)
if until != nil && createdAt.After(*until) {
continue
}
// If we've gone past our date range (older than since), count it
if since != nil && createdAt.Before(*since) {
oldItemsInPage++
continue
}
// Extract issue number from the issue URL
issueNumber := 0
if comment.IssueURL != nil {
// Issue URL format: https://api.github.com/repos/{owner}/{repo}/issues/{number}
parts := strings.Split(*comment.IssueURL, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
issueNumber = num
}
}
}
var author models.Author
if comment.User != nil {
author = models.Author{
Login: comment.User.GetLogin(),
Name: comment.User.GetName(),
AvatarURL: comment.User.GetAvatarURL(),
}
}
ic := models.IssueComment{
ID: comment.GetID(),
Issue: issueNumber,
Repository: fmt.Sprintf("%s/%s", owner, repo),
Author: author,
Body: comment.GetBody(),
CreatedAt: createdAt,
}
allComments = append(allComments, ic)
}
// If all items in this page are older than our range, we can stop
// (since results are sorted by created date descending)
if oldItemsInPage == totalItems && totalItems > 0 {
c.progress(fmt.Sprintf(" Reached issue comments older than date range, stopping early (page %d)", page))
reachedOldItems = true
break
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
if !reachedOldItems && page > 1 {
c.progress(fmt.Sprintf(" Fetched all %d pages of issue comments", page))
}
// Cache results
c.cache.Set(cacheKey, allComments)
return allComments, nil
}
// UserProfile contains GitHub user profile information useful for deduplication
type UserProfile struct {
ID int64 // GitHub user ID
+11 -11
View File
@@ -39,7 +39,7 @@ func TestServer_CacheMiddleware(t *testing.T) {
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
_, _ = w.Write([]byte("OK"))
})
// Wrap with cache middleware
@@ -87,7 +87,7 @@ func TestServer_ServesStaticFiles(t *testing.T) {
// Create a test file with a simple name
testFile := filepath.Join(tempDir, "hello.txt")
err := os.WriteFile(testFile, []byte("Hello, World!"), 0644)
err := os.WriteFile(testFile, []byte("Hello, World!"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -139,7 +139,7 @@ func TestServer_ServesNestedDirectories(t *testing.T) {
// Create a file in nested directory
testFile := filepath.Join(nestedDir, "metrics.json")
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0644)
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0600)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
@@ -164,7 +164,7 @@ func TestServer_MiddlewareCombination(t *testing.T) {
innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("response"))
_, _ = w.Write([]byte("response"))
})
// Combine middlewares like in the actual server
@@ -189,7 +189,7 @@ func TestServer_ServesIndexHtml(t *testing.T) {
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0644)
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0600)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
@@ -213,7 +213,7 @@ func TestServer_CreateHandler(t *testing.T) {
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Handler Test</body></html>"), 0644)
err := os.WriteFile(indexFile, []byte("<html><body>Handler Test</body></html>"), 0600)
require.NoError(t, err)
s := New(tempDir, "8080")
@@ -281,7 +281,7 @@ func TestServer_ServesJSONWithCorrectContentType(t *testing.T) {
// Create a JSON file
jsonFile := filepath.Join(tempDir, "data.json")
err := os.WriteFile(jsonFile, []byte(`{"status": "ok"}`), 0644)
err := os.WriteFile(jsonFile, []byte(`{"status": "ok"}`), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -306,7 +306,7 @@ func TestServer_ServesHTMLWithCorrectContentType(t *testing.T) {
// Create an HTML file
htmlFile := filepath.Join(tempDir, "page.html")
err := os.WriteFile(htmlFile, []byte("<html><body>HTML Page</body></html>"), 0644)
err := os.WriteFile(htmlFile, []byte("<html><body>HTML Page</body></html>"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -331,7 +331,7 @@ func TestServer_CORSHeaders(t *testing.T) {
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
err := os.WriteFile(testFile, []byte("test content"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -354,7 +354,7 @@ func TestServer_CacheDisabledHeaders(t *testing.T) {
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
err := os.WriteFile(testFile, []byte("test content"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -406,7 +406,7 @@ func TestServer_CacheMiddlewarePreservesResponseBody(t *testing.T) {
expectedBody := "This is the response body content"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(expectedBody))
_, _ = w.Write([]byte(expectedBody))
})
wrapped := s.cacheMiddleware(handler)
+28
View File
@@ -176,6 +176,34 @@ const achievements = {
'docs-del-500': { name: 'Dead Code Hunter', description: 'Removed 500 lines of outdated comments', icon: 'fa-skull-crossbones' },
'docs-del-1000': { name: 'Comment Surgeon', description: 'Removed 1000 lines of outdated comments', icon: 'fa-user-doctor' },
'docs-del-2500': { name: 'Noise Eliminator', description: 'Removed 2500 lines of outdated comments', icon: 'fa-volume-xmark' },
// ===== ISSUES OPENED (Tiers: 1, 5, 10, 25, 50) =====
'issue-1': { name: 'Bug Hunter', description: 'Opened your first issue', icon: 'fa-bug' },
'issue-5': { name: 'Issue Reporter', description: 'Opened 5 issues', icon: 'fa-flag' },
'issue-10': { name: 'Quality Advocate', description: 'Opened 10 issues', icon: 'fa-clipboard-list' },
'issue-25': { name: 'Issue Expert', description: 'Opened 25 issues', icon: 'fa-list-check' },
'issue-50': { name: 'Issue Champion', description: 'Opened 50 issues', icon: 'fa-bullhorn' },
// ===== ISSUES CLOSED (Tiers: 1, 5, 10, 25, 50) =====
'issue-close-1': { name: 'Problem Solver', description: 'Closed your first issue', icon: 'fa-circle-check' },
'issue-close-5': { name: 'Bug Squasher', description: 'Closed 5 issues', icon: 'fa-bug-slash' },
'issue-close-10': { name: 'Issue Resolver', description: 'Closed 10 issues', icon: 'fa-check-double' },
'issue-close-25': { name: 'Closure Expert', description: 'Closed 25 issues', icon: 'fa-square-check' },
'issue-close-50': { name: 'Issue Terminator', description: 'Closed 50 issues', icon: 'fa-crosshairs' },
// ===== ISSUE COMMENTS (Tiers: 5, 10, 25, 50, 100) =====
'issue-comment-5': { name: 'Issue Commenter', description: 'Left 5 issue comments', icon: 'fa-comment' },
'issue-comment-10': { name: 'Discussion Starter', description: 'Left 10 issue comments', icon: 'fa-comments' },
'issue-comment-25': { name: 'Issue Collaborator', description: 'Left 25 issue comments', icon: 'fa-people-arrows' },
'issue-comment-50': { name: 'Community Voice', description: 'Left 50 issue comments', icon: 'fa-bullhorn' },
'issue-comment-100': { name: 'Issue Guru', description: 'Left 100 issue comments', icon: 'fa-graduation-cap' },
// ===== ISSUE REFERENCES IN COMMITS (Tiers: 5, 10, 25, 50, 100) =====
'issue-ref-5': { name: 'Issue Linker', description: 'Referenced issues in 5 commits', icon: 'fa-link' },
'issue-ref-10': { name: 'Commit Connector', description: 'Referenced issues in 10 commits', icon: 'fa-diagram-project' },
'issue-ref-25': { name: 'Traceability Pro', description: 'Referenced issues in 25 commits', icon: 'fa-sitemap' },
'issue-ref-50': { name: 'Issue Tracker', description: 'Referenced issues in 50 commits', icon: 'fa-chart-gantt' },
'issue-ref-100': { name: 'Traceability Master', description: 'Referenced issues in 100 commits', icon: 'fa-network-wired' },
}
const getAchievement = (id) => {
+12
View File
@@ -46,6 +46,14 @@ const achievementCategories = {
'docs': ['docs-100', 'docs-500', 'docs-1000', 'docs-2500', 'docs-5000'],
// Documentation deleted
'docs-del': ['docs-del-50', 'docs-del-200', 'docs-del-500', 'docs-del-1000', 'docs-del-2500'],
// Issues opened
'issue': ['issue-1', 'issue-5', 'issue-10', 'issue-25', 'issue-50'],
// Issues closed
'issue-close': ['issue-close-1', 'issue-close-5', 'issue-close-10', 'issue-close-25', 'issue-close-50'],
// Issue comments
'issue-comment': ['issue-comment-5', 'issue-comment-10', 'issue-comment-25', 'issue-comment-50', 'issue-comment-100'],
// Issue references in commits
'issue-ref': ['issue-ref-5', 'issue-ref-10', 'issue-ref-25', 'issue-ref-50', 'issue-ref-100'],
}
// Get the category for an achievement ID
@@ -107,8 +115,12 @@ const categoryPriority = {
'review': 8,
'lines-added': 7,
'perfect-pr': 6,
'issue': 5.5,
'issue-close': 5.4,
'streak': 5,
'active': 4,
'issue-ref': 3.5,
'issue-comment': 3.2,
'review-time': 3,
'docs': 2,
}
+42 -1
View File
@@ -273,6 +273,40 @@ watch(globalData, loadContributor)
</div>
</div>
</div>
<!-- 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">
<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-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-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-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-purple-500 font-semibold">
{{ formatNumber(contributor.issue_references_in_commits || 0) }}
</span>
</div>
</div>
</div>
</div>
</div>
</section>
@@ -285,7 +319,7 @@ watch(globalData, loadContributor)
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<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-2xl font-bold text-green-500">
{{ formatNumber(contributor.score.breakdown.commits || 0) }}
@@ -314,6 +348,13 @@ watch(globalData, loadContributor)
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Comments</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.review_comments || 0 }} × 5 pts</div>
</div>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-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>
<div class="text-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-orange-500">
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }}