Additional checks on issues.

This commit is contained in:
Lukasz Raczylo
2025-12-11 19:43:40 +00:00
parent 78f961be81
commit 53b1301404
25 changed files with 1082 additions and 40 deletions
+86 -1
View File
@@ -408,6 +408,63 @@ func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDat
}
}
// Process issue comments
for _, comment := range data.IssueComments {
login := comment.Author.Login
if login == "" {
continue
}
// Initialize contributor if needed
if _, ok := contributorMap[login]; !ok {
contributorMap[login] = &models.ContributorMetrics{
Login: login,
Period: period,
}
}
cm := contributorMap[login]
cm.IssueComments++
// Track repository participation
if !contains(cm.RepositoriesContributed, comment.Repository) {
cm.RepositoriesContributed = append(cm.RepositoriesContributed, comment.Repository)
}
// Update per-repo contributor metrics
rcm := getRepoContributor(comment.Repository, login, cm.Name, cm.AvatarURL)
rcm.IssueComments++
}
// Count issue references in commits (e.g., "fixes #123", "closes #456", "refs #789")
for _, commit := range data.Commits {
login := commit.Author.Login
if login == "" {
continue
}
// Normalize login
if mappedLogin, ok := emailToLogin[commit.Author.Email]; ok {
login = mappedLogin
}
if mappedLogin, ok := loginToLogin[login]; ok {
login = mappedLogin
}
// Count issue references in commit message
issueRefCount := countIssueReferences(commit.Message)
if issueRefCount > 0 {
if cm, ok := contributorMap[login]; ok {
cm.IssueReferencesInCommits += issueRefCount
}
// Update per-repo contributor metrics
if rcm, ok := repoContributorMap[commit.Repository][login]; ok {
rcm.IssueReferencesInCommits += issueRefCount
}
}
}
// Calculate averages and finalize contributor metrics
for login, cm := range contributorMap {
// Calculate average time to merge
@@ -1272,7 +1329,6 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
// Calculate streaks
longest = 1
current = 1
streak := 1
for i := 1; i < len(dates); i++ {
@@ -1300,3 +1356,32 @@ func calculateStreaks(days map[string]bool) (longest, current int) {
return longest, current
}
// countIssueReferences counts the number of issue references in a commit message
// Detects patterns like: fixes #123, closes #456, resolves #789, refs #12, etc.
func countIssueReferences(message string) int {
count := 0
// Count all #<number> patterns in the message
// This covers both keyword-prefixed references (fixes #123, closes #456)
// and standalone mentions (see #123, just #123)
// We only count each unique position once
for i := 0; i < len(message); i++ {
if message[i] == '#' && i+1 < len(message) {
// Check for digits after #
hasDigits := false
for j := i + 1; j < len(message); j++ {
if message[j] >= '0' && message[j] <= '9' {
hasDigits = true
} else {
break
}
}
if hasDigits {
count++
}
}
}
return count
}
+245
View File
@@ -876,3 +876,248 @@ func TestBuildEmailToLoginMapping_NoReplyEmailWithoutID(t *testing.T) {
// Should map via name matching since there's a PR author with the same name
assert.Equal(t, "johndoe", mapping["johndoe@users.noreply.github.com"])
}
func TestCountIssueReferences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
expected int
}{
{
name: "no references",
message: "Just a regular commit message",
expected: 0,
},
{
name: "fixes issue",
message: "fixes #123",
expected: 1,
},
{
name: "Fixes issue uppercase",
message: "Fixes #456",
expected: 1,
},
{
name: "closes issue",
message: "closes #789",
expected: 1,
},
{
name: "resolves issue",
message: "resolves #101",
expected: 1,
},
{
name: "refs issue",
message: "refs #202",
expected: 1,
},
{
name: "ref issue",
message: "ref #303",
expected: 1,
},
{
name: "multiple fixes",
message: "fixes #1, fixes #2, fixes #3",
expected: 3,
},
{
name: "mixed keywords",
message: "fixes #1 and closes #2",
expected: 2,
},
{
name: "standalone issue reference",
message: "Related to #123",
expected: 1,
},
{
name: "multiple standalone references",
message: "See #1 and #2 for context",
expected: 2,
},
{
name: "fix with extra whitespace",
message: "fix #123",
expected: 1,
},
{
name: "closed past tense",
message: "closed #123",
expected: 1,
},
{
name: "fixed past tense",
message: "fixed #456",
expected: 1,
},
{
name: "resolved past tense",
message: "resolved #789",
expected: 1,
},
{
name: "close without s",
message: "close #123",
expected: 1,
},
{
name: "fix without es",
message: "fix #456",
expected: 1,
},
{
name: "resolve without s",
message: "resolve #789",
expected: 1,
},
{
name: "hash without number",
message: "This is about # something",
expected: 0,
},
{
name: "complex commit message",
message: "feat: Add new feature\n\nThis implements the feature requested in #123.\nAlso fixes #456 and closes #789.",
expected: 3,
},
{
name: "PR style reference",
message: "Merge pull request #100 from feature-branch",
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := countIssueReferences(tt.message)
assert.Equal(t, tt.expected, result, "message: %s", tt.message)
})
}
}
func TestAggregator_IssueComments(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
// Need a commit to create the repository
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
},
IssueComments: []models.IssueComment{
{
ID: 1,
Issue: 1,
Repository: "owner/repo",
Author: models.Author{Login: "user1"},
CreatedAt: time.Now(),
},
{
ID: 2,
Issue: 1,
Repository: "owner/repo",
Author: models.Author{Login: "user1"},
CreatedAt: time.Now(),
},
{
ID: 3,
Issue: 2,
Repository: "owner/repo",
Author: models.Author{Login: "user2"},
CreatedAt: time.Now(),
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
// Check that issue comments are counted
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
// Find user1 and user2
var user1, user2 *models.ContributorMetrics
for i := range repo.Contributors {
if repo.Contributors[i].Login == "user1" {
user1 = &repo.Contributors[i]
}
if repo.Contributors[i].Login == "user2" {
user2 = &repo.Contributors[i]
}
}
require.NotNil(t, user1)
assert.Equal(t, 2, user1.IssueComments) // user1 has 2 comments
require.NotNil(t, user2)
assert.Equal(t, 1, user2.IssueComments) // user2 has 1 comment
}
func TestAggregator_IssueReferencesInCommits(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Message: "fixes #1 and closes #2",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
{
SHA: "def456",
Message: "Regular commit without issue refs",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
{
SHA: "ghi789",
Message: "resolves #3",
Author: models.Author{Login: "user2"},
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
// Find user1 and user2
var user1, user2 *models.ContributorMetrics
for i := range repo.Contributors {
if repo.Contributors[i].Login == "user1" {
user1 = &repo.Contributors[i]
}
if repo.Contributors[i].Login == "user2" {
user2 = &repo.Contributors[i]
}
}
require.NotNil(t, user1)
assert.Equal(t, 2, user1.IssueReferencesInCommits) // user1 has 2 issue references (fixes #1, closes #2)
require.NotNil(t, user2)
assert.Equal(t, 1, user2.IssueReferencesInCommits) // user2 has 1 issue reference (resolves #3)
}
+13
View File
@@ -267,6 +267,19 @@ func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange
}
}
// Fetch issue comments
issueComments, err := a.client.FetchIssueComments(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch issue comments: %w", err)
}
a.log(" Found %d issue comments", len(issueComments))
for _, comment := range issueComments {
if !a.config.IsBot(comment.Author.Login) {
data.IssueComments = append(data.IssueComments, comment)
}
}
return nil
}
+2 -2
View File
@@ -193,7 +193,7 @@ date_range:
// Create temp config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.configYAML), 0644)
err := os.WriteFile(configPath, []byte(tt.configYAML), 0600)
require.NoError(t, err)
// Load config
@@ -940,7 +940,7 @@ func TestLoad_FileNotFound(t *testing.T) {
func TestLoad_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0644)
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0600)
require.NoError(t, err)
_, err = Load(configPath)
+33 -1
View File
@@ -84,6 +84,8 @@ type PointsConfig struct {
ReviewComment int `yaml:"review_comment"` // PR review comments (not code comments)
IssueOpened int `yaml:"issue_opened"`
IssueClosed int `yaml:"issue_closed"`
IssueComment int `yaml:"issue_comment"` // Commenting on an issue
IssueReference int `yaml:"issue_reference_commit"` // Commit referencing an issue (fixes #123, etc.)
FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"`
@@ -197,8 +199,10 @@ func DefaultConfig() *Config {
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
@@ -371,5 +375,33 @@ func defaultAchievements() []AchievementConfig {
{ID: "docs-del-500", Name: "Dead Code Hunter", Description: "Removed 500 lines of outdated comments", Icon: "fa-skull-crossbones", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 500}},
{ID: "docs-del-1000", Name: "Comment Surgeon", Description: "Removed 1000 lines of outdated comments", Icon: "fa-scalpel", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 1000}},
{ID: "docs-del-2500", Name: "Noise Eliminator", Description: "Removed 2500 lines of outdated comments", Icon: "fa-volume-xmark", Condition: AchievementCondition{Type: "comment_lines_deleted", Threshold: 2500}},
// ===== ISSUES OPENED (Tiers: 1, 5, 10, 25, 50) =====
{ID: "issue-1", Name: "Bug Hunter", Description: "Opened your first issue", Icon: "fa-bug", Condition: AchievementCondition{Type: "issues_opened", Threshold: 1}},
{ID: "issue-5", Name: "Issue Reporter", Description: "Opened 5 issues", Icon: "fa-flag", Condition: AchievementCondition{Type: "issues_opened", Threshold: 5}},
{ID: "issue-10", Name: "Quality Advocate", Description: "Opened 10 issues", Icon: "fa-clipboard-list", Condition: AchievementCondition{Type: "issues_opened", Threshold: 10}},
{ID: "issue-25", Name: "Issue Expert", Description: "Opened 25 issues", Icon: "fa-list-check", Condition: AchievementCondition{Type: "issues_opened", Threshold: 25}},
{ID: "issue-50", Name: "Issue Champion", Description: "Opened 50 issues", Icon: "fa-bullhorn", Condition: AchievementCondition{Type: "issues_opened", Threshold: 50}},
// ===== ISSUES CLOSED (Tiers: 1, 5, 10, 25, 50) =====
{ID: "issue-close-1", Name: "Problem Solver", Description: "Closed your first issue", Icon: "fa-circle-check", Condition: AchievementCondition{Type: "issues_closed", Threshold: 1}},
{ID: "issue-close-5", Name: "Bug Squasher", Description: "Closed 5 issues", Icon: "fa-bug-slash", Condition: AchievementCondition{Type: "issues_closed", Threshold: 5}},
{ID: "issue-close-10", Name: "Issue Resolver", Description: "Closed 10 issues", Icon: "fa-check-double", Condition: AchievementCondition{Type: "issues_closed", Threshold: 10}},
{ID: "issue-close-25", Name: "Closure Expert", Description: "Closed 25 issues", Icon: "fa-square-check", Condition: AchievementCondition{Type: "issues_closed", Threshold: 25}},
{ID: "issue-close-50", Name: "Issue Terminator", Description: "Closed 50 issues", Icon: "fa-crosshairs", Condition: AchievementCondition{Type: "issues_closed", Threshold: 50}},
// ===== ISSUE COMMENTS (Tiers: 5, 10, 25, 50, 100) =====
{ID: "issue-comment-5", Name: "Issue Commenter", Description: "Left 5 issue comments", Icon: "fa-comment", Condition: AchievementCondition{Type: "issue_comments", Threshold: 5}},
{ID: "issue-comment-10", Name: "Discussion Starter", Description: "Left 10 issue comments", Icon: "fa-comments", Condition: AchievementCondition{Type: "issue_comments", Threshold: 10}},
{ID: "issue-comment-25", Name: "Issue Collaborator", Description: "Left 25 issue comments", Icon: "fa-people-arrows", Condition: AchievementCondition{Type: "issue_comments", Threshold: 25}},
{ID: "issue-comment-50", Name: "Community Voice", Description: "Left 50 issue comments", Icon: "fa-bullhorn", Condition: AchievementCondition{Type: "issue_comments", Threshold: 50}},
{ID: "issue-comment-100", Name: "Issue Guru", Description: "Left 100 issue comments", Icon: "fa-graduation-cap", Condition: AchievementCondition{Type: "issue_comments", Threshold: 100}},
// ===== ISSUE REFERENCES IN COMMITS (Tiers: 5, 10, 25, 50, 100) =====
{ID: "issue-ref-5", Name: "Issue Linker", Description: "Referenced issues in 5 commits", Icon: "fa-link", Condition: AchievementCondition{Type: "issue_references", Threshold: 5}},
{ID: "issue-ref-10", Name: "Commit Connector", Description: "Referenced issues in 10 commits", Icon: "fa-diagram-project", Condition: AchievementCondition{Type: "issue_references", Threshold: 10}},
{ID: "issue-ref-25", Name: "Traceability Pro", Description: "Referenced issues in 25 commits", Icon: "fa-sitemap", Condition: AchievementCondition{Type: "issue_references", Threshold: 25}},
{ID: "issue-ref-50", Name: "Issue Tracker", Description: "Referenced issues in 50 commits", Icon: "fa-chart-gantt", Condition: AchievementCondition{Type: "issue_references", Threshold: 50}},
{ID: "issue-ref-100", Name: "Traceability Master", Description: "Referenced issues in 100 commits", Icon: "fa-network-wired", Condition: AchievementCondition{Type: "issue_references", Threshold: 100}},
}
}
+5 -3
View File
@@ -49,9 +49,10 @@ type ContributorMetrics struct {
AvgReviewTime float64 `json:"avg_review_time_hours"`
// Issue metrics
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
IssueReferencesInCommits int `json:"issue_references_in_commits"` // Commits referencing issues (fixes #123, etc.)
// Activity patterns
ActiveDays int `json:"active_days"` // Unique days with activity
@@ -87,6 +88,7 @@ type ScoreBreakdown struct {
PRs int `json:"prs"`
Reviews int `json:"reviews"`
Comments int `json:"comments"` // PR review comments (not code comments)
Issues int `json:"issues"` // Issue-related points (opened, closed, comments, references)
ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"`
OutOfHours int `json:"out_of_hours"` // Bonus for out-of-hours commits
+5 -4
View File
@@ -2,8 +2,9 @@ package models
// RawData holds the raw collected data from GitHub
type RawData struct {
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
IssueComments []IssueComment
}
+22 -1
View File
@@ -48,6 +48,11 @@ func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetr
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Issue metrics
existing.IssuesOpened += cm.IssuesOpened
existing.IssuesClosed += cm.IssuesClosed
existing.IssueComments += cm.IssueComments
existing.IssueReferencesInCommits += cm.IssueReferencesInCommits
// Combine unique repositories
for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) {
@@ -181,6 +186,12 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Comment points (PR review comments)
breakdown.Comments = cm.ReviewComments * points.ReviewComment
// Issue points
breakdown.Issues = cm.IssuesOpened*points.IssueOpened +
cm.IssuesClosed*points.IssueClosed +
cm.IssueComments*points.IssueComment +
cm.IssueReferencesInCommits*points.IssueReference
// Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
if cm.AvgReviewTime <= 1 {
@@ -197,7 +208,8 @@ func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments + breakdown.OutOfHours
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments +
breakdown.Issues + breakdown.OutOfHours
return models.Score{
Total: total,
@@ -265,6 +277,15 @@ func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
earned = float64(cm.CommentLinesAdded) >= ach.Condition.Threshold
case "comment_lines_deleted":
earned = float64(cm.CommentLinesDeleted) >= ach.Condition.Threshold
// Issue metrics
case "issues_opened":
earned = float64(cm.IssuesOpened) >= ach.Condition.Threshold
case "issues_closed":
earned = float64(cm.IssuesClosed) >= ach.Condition.Threshold
case "issue_comments":
earned = float64(cm.IssueComments) >= ach.Condition.Threshold
case "issue_references":
earned = float64(cm.IssueReferencesInCommits) >= ach.Condition.Threshold
}
if earned {
+316
View File
@@ -1105,3 +1105,319 @@ func TestCalculator_CommentLinesAchievements(t *testing.T) {
assert.NotContains(t, entry.Achievements, "docs-del-200", "60 < 200")
})
}
func TestCalculator_IssueScoring(t *testing.T) {
t.Parallel()
t.Run("calculates issue points correctly", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
IssueOpened: 10, // 10 points per issue opened
IssueClosed: 20, // 20 points per issue closed
IssueComment: 5, // 5 points per issue comment
IssueReference: 5, // 5 points per issue reference in commit
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
CommitCount: 10,
IssuesOpened: 5, // 5 * 10 = 50
IssuesClosed: 3, // 3 * 20 = 60
IssueComments: 10, // 10 * 5 = 50
IssueReferencesInCommits: 8, // 8 * 5 = 40
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Issue points: 50 + 60 + 50 + 40 = 200
assert.Equal(t, 200, contributor.Score.Breakdown.Issues)
// Commits: 10 * 10 = 100
// Total: 100 + 200 = 300
assert.Equal(t, 300, contributor.Score.Total)
})
t.Run("aggregates issue metrics across repositories", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
IssuesOpened: 3,
IssuesClosed: 2,
IssueComments: 5,
IssueReferencesInCommits: 4,
RepositoriesContributed: []string{"owner/repo1"},
},
},
},
{
FullName: "owner/repo2",
Contributors: []models.ContributorMetrics{
{
Login: "issue-worker",
IssuesOpened: 2,
IssuesClosed: 1,
IssueComments: 3,
IssueReferencesInCommits: 2,
RepositoriesContributed: []string{"owner/repo2"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
// Aggregated: 5 opened, 3 closed, 8 comments, 6 references
// Points: 5*10 + 3*20 + 8*5 + 6*5 = 50 + 60 + 40 + 30 = 180
assert.Equal(t, 180, result.Leaderboard[0].Score)
})
t.Run("zero issue metrics results in zero issue points", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
IssueOpened: 10,
IssueClosed: 20,
IssueComment: 5,
IssueReference: 5,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "code-only",
CommitCount: 20,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, 0, contributor.Score.Breakdown.Issues)
// Only commits: 20 * 10 = 200
assert.Equal(t, 200, contributor.Score.Total)
})
}
func TestCalculator_IssueAchievements(t *testing.T) {
t.Parallel()
t.Run("earns issue opened achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "bug-hunter",
IssuesOpened: 12, // Should earn issue-1, issue-5, issue-10
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-1", "Should earn issue-1 for 1+ issues opened")
assert.Contains(t, contributor.Achievements, "issue-5", "Should earn issue-5 for 5+ issues opened")
assert.Contains(t, contributor.Achievements, "issue-10", "Should earn issue-10 for 10+ issues opened")
assert.NotContains(t, contributor.Achievements, "issue-25", "Should not earn issue-25 for <25 issues")
})
t.Run("earns issue closed achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "problem-solver",
IssuesClosed: 8, // Should earn issue-close-1, issue-close-5
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-close-1", "Should earn issue-close-1 for 1+ issues closed")
assert.Contains(t, contributor.Achievements, "issue-close-5", "Should earn issue-close-5 for 5+ issues closed")
assert.NotContains(t, contributor.Achievements, "issue-close-10", "Should not earn issue-close-10 for <10 issues")
})
t.Run("earns issue comment achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "discusser",
IssueComments: 30, // Should earn issue-comment-5, issue-comment-10, issue-comment-25
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-comment-5", "Should earn issue-comment-5 for 5+ comments")
assert.Contains(t, contributor.Achievements, "issue-comment-10", "Should earn issue-comment-10 for 10+ comments")
assert.Contains(t, contributor.Achievements, "issue-comment-25", "Should earn issue-comment-25 for 25+ comments")
assert.NotContains(t, contributor.Achievements, "issue-comment-50", "Should not earn issue-comment-50 for <50 comments")
})
t.Run("earns issue reference achievements", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "linker",
IssueReferencesInCommits: 15, // Should earn issue-ref-5, issue-ref-10
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
assert.Contains(t, contributor.Achievements, "issue-ref-5", "Should earn issue-ref-5 for 5+ references")
assert.Contains(t, contributor.Achievements, "issue-ref-10", "Should earn issue-ref-10 for 10+ references")
assert.NotContains(t, contributor.Achievements, "issue-ref-25", "Should not earn issue-ref-25 for <25 references")
})
t.Run("earns all issue achievement tiers", func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "super-issue-worker",
IssuesOpened: 100,
IssuesClosed: 100,
IssueComments: 150,
IssueReferencesInCommits: 150,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have all issue opened achievements
assert.Contains(t, contributor.Achievements, "issue-1")
assert.Contains(t, contributor.Achievements, "issue-5")
assert.Contains(t, contributor.Achievements, "issue-10")
assert.Contains(t, contributor.Achievements, "issue-25")
assert.Contains(t, contributor.Achievements, "issue-50")
// Should have all issue closed achievements
assert.Contains(t, contributor.Achievements, "issue-close-1")
assert.Contains(t, contributor.Achievements, "issue-close-5")
assert.Contains(t, contributor.Achievements, "issue-close-10")
assert.Contains(t, contributor.Achievements, "issue-close-25")
assert.Contains(t, contributor.Achievements, "issue-close-50")
// Should have all issue comment achievements
assert.Contains(t, contributor.Achievements, "issue-comment-5")
assert.Contains(t, contributor.Achievements, "issue-comment-10")
assert.Contains(t, contributor.Achievements, "issue-comment-25")
assert.Contains(t, contributor.Achievements, "issue-comment-50")
assert.Contains(t, contributor.Achievements, "issue-comment-100")
// Should have all issue reference achievements
assert.Contains(t, contributor.Achievements, "issue-ref-5")
assert.Contains(t, contributor.Achievements, "issue-ref-10")
assert.Contains(t, contributor.Achievements, "issue-ref-25")
assert.Contains(t, contributor.Achievements, "issue-ref-50")
assert.Contains(t, contributor.Achievements, "issue-ref-100")
})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,9 +8,9 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<script type="module" crossorigin src="./assets/index-LBN7XWrH.js"></script>
<script type="module" crossorigin src="./assets/index-IALpeAps.js"></script>
<link rel="modulepreload" crossorigin href="./assets/chart-Bcjh2pZL.js">
<link rel="stylesheet" crossorigin href="./assets/index-8XjWwD9J.css">
<link rel="stylesheet" crossorigin href="./assets/index-DOVyCPqp.css">
</head>
<body class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 font-sans transition-colors duration-300">
<div id="app"></div>
+124
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -611,6 +612,129 @@ func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, unt
return allIssues, nil
}
// FetchIssueComments fetches comments on issues from a repository
// Uses early termination when sorted by date - stops when items are outside date range
func (c *Client) FetchIssueComments(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.IssueComment, error) {
cacheKey := fmt.Sprintf("issue_comments:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if comments, ok := cached.([]models.IssueComment); ok {
c.progress(" Using cached issue comments data")
return comments, nil
}
}
var allComments []models.IssueComment
// Sort by created date descending - newest first
// This allows us to stop early when we hit items older than our date range
opts := &github.IssueListCommentsOptions{
Sort: github.Ptr("created"),
Direction: github.Ptr("desc"),
ListOptions: github.ListOptions{
PerPage: 100,
},
}
// Set 'since' parameter if provided (GitHub filters by update time but we'll also filter manually)
if since != nil {
opts.Since = since
}
page := 1
reachedOldItems := false
for {
var comments []*github.IssueComment
var resp *github.Response
err := c.retryWithBackoff(ctx, "list issue comments", func() error {
var err error
// Passing empty issue number fetches all comments in the repo
comments, resp, err = c.gh.Issues.ListComments(ctx, owner, repo, 0, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list issue comments: %w", err)
}
c.progress(fmt.Sprintf(" Fetching issue comments page %d (%d comments so far)...", page, len(allComments)))
oldItemsInPage := 0
totalItems := len(comments)
for _, comment := range comments {
createdAt := comment.GetCreatedAt().Time
// Skip items newer than our range (when until is specified)
if until != nil && createdAt.After(*until) {
continue
}
// If we've gone past our date range (older than since), count it
if since != nil && createdAt.Before(*since) {
oldItemsInPage++
continue
}
// Extract issue number from the issue URL
issueNumber := 0
if comment.IssueURL != nil {
// Issue URL format: https://api.github.com/repos/{owner}/{repo}/issues/{number}
parts := strings.Split(*comment.IssueURL, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[len(parts)-1]); err == nil {
issueNumber = num
}
}
}
var author models.Author
if comment.User != nil {
author = models.Author{
Login: comment.User.GetLogin(),
Name: comment.User.GetName(),
AvatarURL: comment.User.GetAvatarURL(),
}
}
ic := models.IssueComment{
ID: comment.GetID(),
Issue: issueNumber,
Repository: fmt.Sprintf("%s/%s", owner, repo),
Author: author,
Body: comment.GetBody(),
CreatedAt: createdAt,
}
allComments = append(allComments, ic)
}
// If all items in this page are older than our range, we can stop
// (since results are sorted by created date descending)
if oldItemsInPage == totalItems && totalItems > 0 {
c.progress(fmt.Sprintf(" Reached issue comments older than date range, stopping early (page %d)", page))
reachedOldItems = true
break
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
if !reachedOldItems && page > 1 {
c.progress(fmt.Sprintf(" Fetched all %d pages of issue comments", page))
}
// Cache results
c.cache.Set(cacheKey, allComments)
return allComments, nil
}
// UserProfile contains GitHub user profile information useful for deduplication
type UserProfile struct {
ID int64 // GitHub user ID
+11 -11
View File
@@ -39,7 +39,7 @@ func TestServer_CacheMiddleware(t *testing.T) {
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
_, _ = w.Write([]byte("OK"))
})
// Wrap with cache middleware
@@ -87,7 +87,7 @@ func TestServer_ServesStaticFiles(t *testing.T) {
// Create a test file with a simple name
testFile := filepath.Join(tempDir, "hello.txt")
err := os.WriteFile(testFile, []byte("Hello, World!"), 0644)
err := os.WriteFile(testFile, []byte("Hello, World!"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -139,7 +139,7 @@ func TestServer_ServesNestedDirectories(t *testing.T) {
// Create a file in nested directory
testFile := filepath.Join(nestedDir, "metrics.json")
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0644)
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0600)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
@@ -164,7 +164,7 @@ func TestServer_MiddlewareCombination(t *testing.T) {
innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("response"))
_, _ = w.Write([]byte("response"))
})
// Combine middlewares like in the actual server
@@ -189,7 +189,7 @@ func TestServer_ServesIndexHtml(t *testing.T) {
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0644)
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0600)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
@@ -213,7 +213,7 @@ func TestServer_CreateHandler(t *testing.T) {
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Handler Test</body></html>"), 0644)
err := os.WriteFile(indexFile, []byte("<html><body>Handler Test</body></html>"), 0600)
require.NoError(t, err)
s := New(tempDir, "8080")
@@ -281,7 +281,7 @@ func TestServer_ServesJSONWithCorrectContentType(t *testing.T) {
// Create a JSON file
jsonFile := filepath.Join(tempDir, "data.json")
err := os.WriteFile(jsonFile, []byte(`{"status": "ok"}`), 0644)
err := os.WriteFile(jsonFile, []byte(`{"status": "ok"}`), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -306,7 +306,7 @@ func TestServer_ServesHTMLWithCorrectContentType(t *testing.T) {
// Create an HTML file
htmlFile := filepath.Join(tempDir, "page.html")
err := os.WriteFile(htmlFile, []byte("<html><body>HTML Page</body></html>"), 0644)
err := os.WriteFile(htmlFile, []byte("<html><body>HTML Page</body></html>"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -331,7 +331,7 @@ func TestServer_CORSHeaders(t *testing.T) {
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
err := os.WriteFile(testFile, []byte("test content"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -354,7 +354,7 @@ func TestServer_CacheDisabledHeaders(t *testing.T) {
// Create a test file
testFile := filepath.Join(tempDir, "test.txt")
err := os.WriteFile(testFile, []byte("test content"), 0644)
err := os.WriteFile(testFile, []byte("test content"), 0600)
require.NoError(t, err)
s := New(tempDir, "0")
@@ -406,7 +406,7 @@ func TestServer_CacheMiddlewarePreservesResponseBody(t *testing.T) {
expectedBody := "This is the response body content"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(expectedBody))
_, _ = w.Write([]byte(expectedBody))
})
wrapped := s.cacheMiddleware(handler)