mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-05 22:43:56 +00:00
Additional checks on issues.
This commit is contained in:
@@ -7,3 +7,4 @@ web/dist/
|
|||||||
web/public/data
|
web/public/data
|
||||||
config.yaml
|
config.yaml
|
||||||
.claude
|
.claude
|
||||||
|
public-config.yaml
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.repos
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type ContributorMetrics struct {
|
|||||||
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
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ type RawData struct {
|
|||||||
PullRequests []PullRequest
|
PullRequests []PullRequest
|
||||||
Reviews []Review
|
Reviews []Review
|
||||||
Issues []Issue
|
Issues []Issue
|
||||||
|
IssueComments []IssueComment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -8,9 +8,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link 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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) }}
|
||||||
|
|||||||
Reference in New Issue
Block a user