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 web/public/data
config.yaml config.yaml
.claude .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 # Build configuration
BINARY_NAME := git-velocity BINARY_NAME := git-velocity
@@ -57,6 +57,11 @@ lint:
@echo "Running linter..." @echo "Running linter..."
@golangci-lint run ./... @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 ## Run Vue dev server for frontend development
dev-spa: dev-spa:
@mkdir -p ./dist/data # Ensure data dir exists for symlink @mkdir -p ./dist/data # Ensure data dir exists for symlink
@@ -95,6 +100,7 @@ help:
@echo " test Run tests with race detector" @echo " test Run tests with race detector"
@echo " test-coverage Run tests with coverage report" @echo " test-coverage Run tests with coverage report"
@echo " lint Run golangci-lint" @echo " lint Run golangci-lint"
@echo " security Run gosec security scanner"
@echo " dev-spa Run Vue dev server" @echo " dev-spa Run Vue dev server"
@echo " dev Run analyzer with sample config" @echo " dev Run analyzer with sample config"
@echo " serve Serve generated output locally" @echo " serve Serve generated output locally"
+13 -3
View File
@@ -51,7 +51,7 @@ $ git-velocity serve --port 8080
### 🎮 Gamification Engine ### 🎮 Gamification Engine
- **Scoring System**: Earn points for every contribution - **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 - **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, and out-of-hours commits
@@ -226,7 +226,7 @@ jobs:
## 🏆 Achievements ## 🏆 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 ### 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 | | **Out of Hours** | 10, 25, 50, 100 | Commits outside 9am-5pm |
| **Documentation** | 100, 500, 1K, 2.5K, 5K | Comment/doc lines added | | **Documentation** | 100, 500, 1K, 2.5K, 5K | Comment/doc lines added |
| **Comment Cleanup** | 50, 200, 500, 1K, 2.5K | Outdated comments removed | | **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 ### 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 | | 🏛️ Code Historian | Added 5000 lines of comments/docs |
| ✂️ Comment Trimmer | Removed 50 outdated comment lines | | ✂️ Comment Trimmer | Removed 50 outdated comment lines |
| 💀 Dead Code Hunter | Removed 500 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 ## 🔑 GitHub Token Permissions
@@ -392,8 +400,10 @@ scoring:
pr_merged: 50 pr_merged: 50
pr_reviewed: 30 pr_reviewed: 30
review_comment: 5 review_comment: 5
issue_opened: 15 issue_opened: 10
issue_closed: 20 issue_closed: 20
issue_comment: 5
issue_reference_commit: 5
fast_review_1h: 50 fast_review_1h: 50
fast_review_4h: 25 fast_review_4h: 25
fast_review_24h: 10 fast_review_24h: 10
+89 -4
View File
@@ -181,7 +181,7 @@
Score Formula Score Formula
</h3> </h3>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"> <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: Where:
Commits = commit_count × 10 points Commits = commit_count × 10 points
@@ -189,6 +189,7 @@ Where:
PRs = (PRs_opened × 25) + (PRs_merged × 50) points PRs = (PRs_opened × 25) + (PRs_merged × 50) points
Reviews = reviews_given × 30 points Reviews = reviews_given × 30 points
Comments = review_comments × 5 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) Response = bonus for fast review response (0-50 points)
Out of Hours = commits outside 9am-5pm × 2 points</code></pre> Out of Hours = commits outside 9am-5pm × 2 points</code></pre>
</div> </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 font-mono text-pink-600 dark:text-pink-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> <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"><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 font-mono text-pink-600 dark:text-pink-400">2</td>
<td class="py-3">Per commit outside 9am-5pm</td> <td class="py-3">Per commit outside 9am-5pm</td>
</tr> </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> </tbody>
</table> </table>
</div> </div>
@@ -433,7 +454,7 @@ Where:
<div class="max-w-6xl mx-auto px-4 sm:px-6"> <div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12"> <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> <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>
<div class="max-w-4xl mx-auto space-y-6"> <div class="max-w-4xl mx-auto space-y-6">
<!-- Achievement Categories --> <!-- Achievement Categories -->
@@ -533,6 +554,46 @@ Where:
Commits at different times of day unlock special badges Commits at different times of day unlock special badges
</div> </div>
</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>
</div> </div>
@@ -586,11 +647,31 @@ Where:
<td class="py-2">PRs with no changes requested</td> <td class="py-2">PRs with no changes requested</td>
<td class="py-2">≥ threshold</td> <td class="py-2">≥ threshold</td>
</tr> </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 font-mono text-xs">repo_count</td>
<td class="py-2">Repositories contributed to</td> <td class="py-2">Repositories contributed to</td>
<td class="py-2">≥ threshold</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">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> </tbody>
</table> </table>
</div> </div>
@@ -714,6 +795,10 @@ Where:
<strong class="text-gray-900 dark:text-gray-100">Unique Reviewees</strong> <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> <p class="text-gray-500 dark:text-gray-400">Count of distinct PR authors reviewed</p>
</div> </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>
</div> </div>
+4 -4
View File
@@ -210,7 +210,7 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6"> <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 class="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div> <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 class="text-sm text-gray-600 dark:text-gray-400">Achievements</div>
</div> </div>
<div> <div>
@@ -255,7 +255,7 @@
</div> </div>
<div> <div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Gamification Engine</h3> <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> </div>
</div> </div>
@@ -415,7 +415,7 @@
<div class="max-w-6xl mx-auto px-4 sm:px-6"> <div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center mb-8 sm:mb-12"> <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> <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>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Commit Achievements --> <!-- Commit Achievements -->
@@ -509,7 +509,7 @@
</div> </div>
</div> </div>
<div class="text-center mt-8"> <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>
</div> </div>
</section> </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 // Calculate averages and finalize contributor metrics
for login, cm := range contributorMap { for login, cm := range contributorMap {
// Calculate average time to merge // Calculate average time to merge
@@ -1272,7 +1329,6 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
// Calculate streaks // Calculate streaks
longest = 1 longest = 1
current = 1
streak := 1 streak := 1
for i := 1; i < len(dates); i++ { for i := 1; i < len(dates); i++ {
@@ -1300,3 +1356,32 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
return longest, current 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 // 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"]) 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 return nil
} }
+2 -2
View File
@@ -193,7 +193,7 @@ date_range:
// Create temp config file // Create temp config file
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml") 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) require.NoError(t, err)
// Load config // Load config
@@ -940,7 +940,7 @@ func TestLoad_FileNotFound(t *testing.T) {
func TestLoad_InvalidYAML(t *testing.T) { func TestLoad_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml") 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) require.NoError(t, err)
_, err = Load(configPath) _, 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) ReviewComment int `yaml:"review_comment"` // PR review comments (not code comments)
IssueOpened int `yaml:"issue_opened"` IssueOpened int `yaml:"issue_opened"`
IssueClosed int `yaml:"issue_closed"` 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"` 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"`
@@ -197,8 +199,10 @@ func DefaultConfig() *Config {
PRMerged: 50, PRMerged: 50,
PRReviewed: 30, PRReviewed: 30,
ReviewComment: 5, ReviewComment: 5,
IssueOpened: 15, IssueOpened: 10,
IssueClosed: 20, IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
FastReview1h: 50, FastReview1h: 50,
FastReview4h: 25, FastReview4h: 25,
FastReview24h: 10, 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-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-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}}, {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"` AvgReviewTime float64 `json:"avg_review_time_hours"`
// Issue metrics // Issue metrics
IssuesOpened int `json:"issues_opened"` IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"` IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"` IssueComments int `json:"issue_comments"`
IssueReferencesInCommits int `json:"issue_references_in_commits"` // Commits referencing issues (fixes #123, etc.)
// Activity patterns // Activity patterns
ActiveDays int `json:"active_days"` // Unique days with activity ActiveDays int `json:"active_days"` // Unique days with activity
@@ -87,6 +88,7 @@ type ScoreBreakdown struct {
PRs int `json:"prs"` PRs int `json:"prs"`
Reviews int `json:"reviews"` Reviews int `json:"reviews"`
Comments int `json:"comments"` // PR review comments (not code comments) 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"` ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"` LineChanges int `json:"line_changes"`
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits 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 // RawData holds the raw collected data from GitHub
type RawData struct { type RawData struct {
Commits []Commit Commits []Commit
PullRequests []PullRequest PullRequests []PullRequest
Reviews []Review Reviews []Review
Issues []Issue 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.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments 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 // Combine unique repositories
for _, r := range cm.RepositoriesContributed { for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) { if !contains(existing.RepositoriesContributed, r) {
@@ -181,6 +186,12 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Comment points (PR review comments) // Comment points (PR review comments)
breakdown.Comments = cm.ReviewComments * points.ReviewComment 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 // Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 { if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
if cm.AvgReviewTime <= 1 { if cm.AvgReviewTime <= 1 {
@@ -197,7 +208,8 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Calculate total // Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs + 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{ return models.Score{
Total: total, Total: total,
@@ -265,6 +277,15 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
earned = float64(cm.CommentLinesAdded) >= ach.Condition.Threshold earned = float64(cm.CommentLinesAdded) >= ach.Condition.Threshold
case "comment_lines_deleted": case "comment_lines_deleted":
earned = float64(cm.CommentLinesDeleted) >= ach.Condition.Threshold 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 { 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") 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 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-LBN7XWrH.js"></script> <script type="module" crossorigin src="./assets/index-IALpeAps.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-8XjWwD9J.css"> <link rel="stylesheet" crossorigin href="./assets/index-DOVyCPqp.css">
</head> </head>
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300"> <body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
<div id="app"></div> <div id="app"></div>
+124
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -611,6 +612,129 @@ func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, unt
return allIssues, nil 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 // UserProfile contains GitHub user profile information useful for deduplication
type UserProfile struct { type UserProfile struct {
ID int64 // GitHub user ID ID int64 // GitHub user ID
+11 -11
View File
@@ -39,7 +39,7 @@ func TestServer_CacheMiddleware(t *testing.T) {
// Create a test handler // Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("OK")) _, _ = w.Write([]byte("OK"))
}) })
// Wrap with cache middleware // Wrap with cache middleware
@@ -87,7 +87,7 @@ func TestServer_ServesStaticFiles(t *testing.T) {
// Create a test file with a simple name // Create a test file with a simple name
testFile := filepath.Join(tempDir, "hello.txt") 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) require.NoError(t, err)
s := New(tempDir, "0") s := New(tempDir, "0")
@@ -139,7 +139,7 @@ func TestServer_ServesNestedDirectories(t *testing.T) {
// Create a file in nested directory // Create a file in nested directory
testFile := filepath.Join(nestedDir, "metrics.json") 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) require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir) absPath, _ := filepath.Abs(tempDir)
@@ -164,7 +164,7 @@ func TestServer_MiddlewareCombination(t *testing.T) {
innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("response")) _, _ = w.Write([]byte("response"))
}) })
// Combine middlewares like in the actual server // Combine middlewares like in the actual server
@@ -189,7 +189,7 @@ func TestServer_ServesIndexHtml(t *testing.T) {
// Create an index.html // Create an index.html
indexFile := filepath.Join(tempDir, "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) require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir) absPath, _ := filepath.Abs(tempDir)
@@ -213,7 +213,7 @@ func TestServer_CreateHandler(t *testing.T) {
// Create an index.html // Create an index.html
indexFile := filepath.Join(tempDir, "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) require.NoError(t, err)
s := New(tempDir, "8080") s := New(tempDir, "8080")
@@ -281,7 +281,7 @@ func TestServer_ServesJSONWithCorrectContentType(t *testing.T) {
// Create a JSON file // Create a JSON file
jsonFile := filepath.Join(tempDir, "data.json") 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) require.NoError(t, err)
s := New(tempDir, "0") s := New(tempDir, "0")
@@ -306,7 +306,7 @@ func TestServer_ServesHTMLWithCorrectContentType(t *testing.T) {
// Create an HTML file // Create an HTML file
htmlFile := filepath.Join(tempDir, "page.html") 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) require.NoError(t, err)
s := New(tempDir, "0") s := New(tempDir, "0")
@@ -331,7 +331,7 @@ func TestServer_CORSHeaders(t *testing.T) {
// Create a test file // Create a test file
testFile := filepath.Join(tempDir, "test.txt") 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) require.NoError(t, err)
s := New(tempDir, "0") s := New(tempDir, "0")
@@ -354,7 +354,7 @@ func TestServer_CacheDisabledHeaders(t *testing.T) {
// Create a test file // Create a test file
testFile := filepath.Join(tempDir, "test.txt") 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) require.NoError(t, err)
s := New(tempDir, "0") s := New(tempDir, "0")
@@ -406,7 +406,7 @@ func TestServer_CacheMiddlewarePreservesResponseBody(t *testing.T) {
expectedBody := "This is the response body content" expectedBody := "This is the response body content"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(expectedBody)) _, _ = w.Write([]byte(expectedBody))
}) })
wrapped := s.cacheMiddleware(handler) 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-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-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' }, '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) => { const getAchievement = (id) => {
+12
View File
@@ -46,6 +46,14 @@ const achievementCategories = {
'docs': ['docs-100', 'docs-500', 'docs-1000', 'docs-2500', 'docs-5000'], 'docs': ['docs-100', 'docs-500', 'docs-1000', 'docs-2500', 'docs-5000'],
// Documentation deleted // Documentation deleted
'docs-del': ['docs-del-50', 'docs-del-200', 'docs-del-500', 'docs-del-1000', 'docs-del-2500'], '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 // Get the category for an achievement ID
@@ -107,8 +115,12 @@ const categoryPriority = {
'review': 8, 'review': 8,
'lines-added': 7, 'lines-added': 7,
'perfect-pr': 6, 'perfect-pr': 6,
'issue': 5.5,
'issue-close': 5.4,
'streak': 5, 'streak': 5,
'active': 4, 'active': 4,
'issue-ref': 3.5,
'issue-comment': 3.2,
'review-time': 3, 'review-time': 3,
'docs': 2, 'docs': 2,
} }
+42 -1
View File
@@ -273,6 +273,40 @@ watch(globalData, loadContributor)
</div> </div>
</div> </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>
</div> </div>
</section> </section>
@@ -285,7 +319,7 @@ watch(globalData, loadContributor)
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown <i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
</h3> </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-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-green-500"> <div class="text-2xl font-bold text-green-500">
{{ formatNumber(contributor.score.breakdown.commits || 0) }} {{ 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-500 dark:text-gray-400 mt-1">Comments</div>
<div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.review_comments || 0 }} × 5 pts</div> <div class="text-xs text-gray-400 dark:text-gray-500">{{ contributor.review_comments || 0 }} × 5 pts</div>
</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-center p-4 rounded-lg bg-gray-50 dark:bg-gray-800/50">
<div class="text-2xl font-bold text-orange-500"> <div class="text-2xl font-bold text-orange-500">
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }} {{ formatNumber(contributor.score.breakdown.line_changes || 0) }}