Initial commit.

This commit is contained in:
2025-12-10 21:09:25 +00:00
commit 9d4de0e6b6
73 changed files with 15219 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
package models
// Author represents a Git/GitHub author
type Author struct {
ID int64 `json:"id,omitempty"`
Login string `json:"login"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// DisplayName returns the best available name for display
func (a *Author) DisplayName() string {
if a.Name != "" {
return a.Name
}
if a.Login != "" {
return a.Login
}
if a.Email != "" {
return a.Email
}
return "Unknown"
}
+43
View File
@@ -0,0 +1,43 @@
package models
import "time"
// Commit represents a Git commit
type Commit struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author Author `json:"author"`
Committer Author `json:"committer"`
Date time.Time `json:"date"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
Repository string `json:"repository"` // owner/repo format
URL string `json:"url"`
// Derived fields
HasTests bool `json:"has_tests"`
}
// TotalChanges returns the total lines changed (additions + deletions)
func (c *Commit) TotalChanges() int {
return c.Additions + c.Deletions
}
// ShortSHA returns the first 7 characters of the SHA
func (c *Commit) ShortSHA() string {
if len(c.SHA) >= 7 {
return c.SHA[:7]
}
return c.SHA
}
// ShortMessage returns the first line of the commit message
func (c *Commit) ShortMessage() string {
for i, r := range c.Message {
if r == '\n' {
return c.Message[:i]
}
}
return c.Message
}
+54
View File
@@ -0,0 +1,54 @@
package models
import "time"
// IssueState represents the state of an issue
type IssueState string
const (
IssueStateOpen IssueState = "open"
IssueStateClosed IssueState = "closed"
)
// Issue represents a GitHub issue
type Issue struct {
Number int `json:"number"`
Title string `json:"title"`
State IssueState `json:"state"`
Author Author `json:"author"`
Repository string `json:"repository"` // owner/repo format
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
ClosedBy *Author `json:"closed_by,omitempty"`
Comments int `json:"comments"`
Labels []string `json:"labels,omitempty"`
URL string `json:"url"`
// Derived fields
TimeToClose *time.Duration `json:"time_to_close,omitempty"`
}
// IsClosed returns true if the issue is closed
func (i *Issue) IsClosed() bool {
return i.State == IssueStateClosed
}
// CalculateTimeToClose calculates the time from issue creation to close
func (i *Issue) CalculateTimeToClose() *time.Duration {
if i.ClosedAt == nil {
return nil
}
d := i.ClosedAt.Sub(i.CreatedAt)
return &d
}
// IssueComment represents a comment on an issue
type IssueComment struct {
ID int64 `json:"id"`
Issue int `json:"issue"`
Repository string `json:"repository"`
Author Author `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
}
+208
View File
@@ -0,0 +1,208 @@
package models
import "time"
// Period represents a time period for metrics aggregation
type Period struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
Granularity string `json:"granularity"` // daily, weekly, monthly, custom
Label string `json:"label"` // e.g., "Week 42", "December 2024", "Q1 2024"
}
// ContributorMetrics holds aggregated metrics for a single contributor
type ContributorMetrics struct {
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Period Period `json:"period"`
// Commit metrics
CommitCount int `json:"commit_count"`
LinesAdded int `json:"lines_added"`
LinesDeleted int `json:"lines_deleted"`
FilesChanged int `json:"files_changed"`
// PR metrics
PRsOpened int `json:"prs_opened"`
PRsMerged int `json:"prs_merged"`
PRsClosed int `json:"prs_closed"`
AvgPRSize float64 `json:"avg_pr_size"`
AvgTimeToMerge float64 `json:"avg_time_to_merge_hours"`
LargestPRSize int `json:"largest_pr_size"` // Biggest single PR by lines changed
SmallPRCount int `json:"small_pr_count"` // PRs under 100 lines (good practice)
PerfectPRs int `json:"perfect_prs"` // PRs merged without changes requested
// Review metrics
ReviewsGiven int `json:"reviews_given"`
ReviewComments int `json:"review_comments"`
ApprovalsGiven int `json:"approvals_given"`
ChangesRequested int `json:"changes_requested"`
AvgReviewTime float64 `json:"avg_review_time_hours"`
// Issue metrics
IssuesOpened int `json:"issues_opened"`
IssuesClosed int `json:"issues_closed"`
IssueComments int `json:"issue_comments"`
// Activity patterns
ActiveDays int `json:"active_days"` // Unique days with activity
CurrentStreak int `json:"current_streak"` // Current consecutive days
LongestStreak int `json:"longest_streak"` // Longest consecutive days
EarlyBirdCount int `json:"early_bird_count"` // Commits before 9am
NightOwlCount int `json:"night_owl_count"` // Commits after 9pm
MidnightCount int `json:"midnight_count"` // Commits between midnight and 4am
WeekendWarrior int `json:"weekend_warrior"` // Weekend commits
// Repository participation
RepositoriesContributed []string `json:"repositories_contributed,omitempty"`
UniqueReviewees int `json:"unique_reviewees"`
// Scoring
Score Score `json:"score"`
Achievements []string `json:"achievements"` // Achievement IDs
}
// Score holds the calculated score and breakdown
type Score struct {
Total int `json:"total"`
Breakdown ScoreBreakdown `json:"breakdown"`
Rank int `json:"rank"`
PercentileRank float64 `json:"percentile_rank"`
}
// ScoreBreakdown shows how the score was calculated
type ScoreBreakdown struct {
Commits int `json:"commits"`
PRs int `json:"prs"`
Reviews int `json:"reviews"`
Comments int `json:"comments"` // PR review comments (not code comments)
ResponseBonus int `json:"response_bonus"`
LineChanges int `json:"line_changes"`
}
// RepositoryMetrics holds aggregated metrics for a single repository
type RepositoryMetrics struct {
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"` // owner/name
Period Period `json:"period"`
Contributors []ContributorMetrics `json:"contributors"`
TotalCommits int `json:"total_commits"`
TotalPRs int `json:"total_prs"`
TotalReviews int `json:"total_reviews"`
ActiveContributors int `json:"active_contributors"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
}
// TeamMetrics holds aggregated metrics for a team
type TeamMetrics struct {
Name string `json:"name"`
Color string `json:"color"`
Members []string `json:"members"`
Period Period `json:"period"`
AggregatedMetrics ContributorMetrics `json:"aggregated_metrics"`
MemberMetrics []ContributorMetrics `json:"member_metrics"`
TotalScore int `json:"total_score"`
AvgScore float64 `json:"avg_score"`
}
// GlobalMetrics holds metrics aggregated across all repositories
type GlobalMetrics struct {
Period Period `json:"period"`
Repositories []RepositoryMetrics `json:"repositories"`
Teams []TeamMetrics `json:"teams"`
Leaderboard []LeaderboardEntry `json:"leaderboard"`
TopAchievers map[string]string `json:"top_achievers"` // category -> login
// Summary stats
TotalContributors int `json:"total_contributors"`
TotalCommits int `json:"total_commits"`
TotalPRs int `json:"total_prs"`
TotalReviews int `json:"total_reviews"`
TotalLinesAdded int `json:"total_lines_added"`
TotalLinesDeleted int `json:"total_lines_deleted"`
// Velocity timeline (weekly granularity)
VelocityTimeline *VelocityTimeline `json:"velocity_timeline,omitempty"`
}
// VelocityTimeline holds weekly velocity data for trend visualization
type VelocityTimeline struct {
Labels []string `json:"labels"` // Week labels (e.g., "Dec 2", "Dec 9")
Series []VelocityTimelineSeries `json:"series"` // Data series (commits, PRs, reviews, score)
}
// VelocityTimelineSeries represents a single data series in the velocity timeline
type VelocityTimelineSeries struct {
Name string `json:"name"` // Series name (e.g., "Commits", "PRs", "Score")
Color string `json:"color"` // Series color
Data []float64 `json:"data"` // Values for each week
}
// LeaderboardEntry represents a single entry in the leaderboard
type LeaderboardEntry struct {
Rank int `json:"rank"`
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
Score int `json:"score"`
Team string `json:"team,omitempty"`
TopCategory string `json:"top_category,omitempty"` // What they're best at
Achievements []string `json:"achievements,omitempty"` // Achievement IDs earned
}
// TimeSeriesPoint represents a single data point in a time series
type TimeSeriesPoint struct {
Date time.Time `json:"date"`
Label string `json:"label"`
Value float64 `json:"value"`
}
// TimeSeries represents a series of data points over time
type TimeSeries struct {
Name string `json:"name"`
Color string `json:"color,omitempty"`
Points []TimeSeriesPoint `json:"points"`
}
// ChartData holds data formatted for charts
type ChartData struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Type string `json:"type"` // line, bar, pie, doughnut
Labels []string `json:"labels"`
Series []TimeSeries `json:"series"`
}
// DashboardData holds all data needed for the dashboard
type DashboardData struct {
GeneratedAt time.Time `json:"generated_at"`
Period Period `json:"period"`
GlobalMetrics GlobalMetrics `json:"global_metrics"`
Charts []ChartData `json:"charts"`
Achievements []Achievement `json:"achievements"`
Configuration DashboardConfig `json:"configuration"`
}
// DashboardConfig holds UI configuration
type DashboardConfig struct {
Title string `json:"title"`
Description string `json:"description,omitempty"`
Repositories []string `json:"repositories"`
Teams []string `json:"teams,omitempty"`
Granularities []string `json:"granularities"`
ScoringEnabled bool `json:"scoring_enabled"`
ShowAchievements bool `json:"show_achievements"`
}
// Achievement represents an earned achievement badge
type Achievement struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
EarnedBy string `json:"earned_by"` // Login of user who earned it
EarnedAt string `json:"earned_at"` // When it was earned (period label)
}
+398
View File
@@ -0,0 +1,398 @@
package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAuthor_DisplayName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
author Author
expected string
}{
{
name: "prefers name over login",
author: Author{Login: "johndoe", Name: "John Doe", Email: "john@example.com"},
expected: "John Doe",
},
{
name: "falls back to login",
author: Author{Login: "johndoe", Email: "john@example.com"},
expected: "johndoe",
},
{
name: "falls back to email",
author: Author{Email: "john@example.com"},
expected: "john@example.com",
},
{
name: "returns Unknown when empty",
author: Author{},
expected: "Unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.author.DisplayName())
})
}
}
func TestCommit_TotalChanges(t *testing.T) {
t.Parallel()
commit := Commit{Additions: 100, Deletions: 50}
assert.Equal(t, 150, commit.TotalChanges())
}
func TestCommit_ShortSHA(t *testing.T) {
t.Parallel()
tests := []struct {
name string
sha string
expected string
}{
{
name: "full SHA",
sha: "abc123456789def",
expected: "abc1234",
},
{
name: "short SHA",
sha: "abc",
expected: "abc",
},
{
name: "exactly 7 chars",
sha: "abc1234",
expected: "abc1234",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
commit := Commit{SHA: tt.sha}
assert.Equal(t, tt.expected, commit.ShortSHA())
})
}
}
func TestCommit_ShortMessage(t *testing.T) {
t.Parallel()
tests := []struct {
name string
message string
expected string
}{
{
name: "single line",
message: "Fix bug in login",
expected: "Fix bug in login",
},
{
name: "multiline",
message: "Fix bug in login\n\nThis fixes the issue where users couldn't log in.",
expected: "Fix bug in login",
},
{
name: "empty",
message: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
commit := Commit{Message: tt.message}
assert.Equal(t, tt.expected, commit.ShortMessage())
})
}
}
func TestPullRequest_IsMerged(t *testing.T) {
t.Parallel()
now := time.Now()
tests := []struct {
name string
pr PullRequest
expected bool
}{
{
name: "merged state",
pr: PullRequest{State: PRStateMerged},
expected: true,
},
{
name: "has merged_at",
pr: PullRequest{State: PRStateClosed, MergedAt: &now},
expected: true,
},
{
name: "open PR",
pr: PullRequest{State: PRStateOpen},
expected: false,
},
{
name: "closed without merge",
pr: PullRequest{State: PRStateClosed},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.pr.IsMerged())
})
}
}
func TestPullRequest_Size(t *testing.T) {
t.Parallel()
tests := []struct {
name string
additions int
deletions int
expected PRSize
}{
{
name: "xs",
additions: 5,
deletions: 3,
expected: PRSizeXS,
},
{
name: "s",
additions: 30,
deletions: 15,
expected: PRSizeS,
},
{
name: "m",
additions: 100,
deletions: 50,
expected: PRSizeM,
},
{
name: "l",
additions: 300,
deletions: 100,
expected: PRSizeL,
},
{
name: "xl",
additions: 400,
deletions: 200,
expected: PRSizeXL,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
pr := PullRequest{Additions: tt.additions, Deletions: tt.deletions}
assert.Equal(t, tt.expected, pr.Size())
})
}
}
func TestPullRequest_CalculateTimeToMerge(t *testing.T) {
t.Parallel()
t.Run("returns duration when merged", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
merged := time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
pr := PullRequest{CreatedAt: created, MergedAt: &merged}
result := pr.CalculateTimeToMerge()
assert.NotNil(t, result)
assert.Equal(t, 4*time.Hour, *result)
})
t.Run("returns nil when not merged", func(t *testing.T) {
t.Parallel()
pr := PullRequest{CreatedAt: time.Now()}
assert.Nil(t, pr.CalculateTimeToMerge())
})
}
func TestPullRequest_CalculateTimeToFirstReview(t *testing.T) {
t.Parallel()
t.Run("returns duration to first review", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
review1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
review2 := time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC)
pr := PullRequest{
CreatedAt: created,
Reviews: []Review{
{SubmittedAt: review2},
{SubmittedAt: review1}, // Earlier review
},
}
result := pr.CalculateTimeToFirstReview()
assert.NotNil(t, result)
assert.Equal(t, 2*time.Hour, *result)
})
t.Run("returns nil when no reviews", func(t *testing.T) {
t.Parallel()
pr := PullRequest{CreatedAt: time.Now(), Reviews: []Review{}}
assert.Nil(t, pr.CalculateTimeToFirstReview())
})
}
func TestReview_IsApproval(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state ReviewState
expected bool
}{
{name: "approved", state: ReviewApproved, expected: true},
{name: "changes requested", state: ReviewChangesRequested, expected: false},
{name: "commented", state: ReviewCommented, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := Review{State: tt.state}
assert.Equal(t, tt.expected, r.IsApproval())
})
}
}
func TestReview_RequestsChanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state ReviewState
expected bool
}{
{name: "approved", state: ReviewApproved, expected: false},
{name: "changes requested", state: ReviewChangesRequested, expected: true},
{name: "commented", state: ReviewCommented, expected: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
r := Review{State: tt.state}
assert.Equal(t, tt.expected, r.RequestsChanges())
})
}
}
func TestReview_IsSubstantive(t *testing.T) {
t.Parallel()
tests := []struct {
name string
review Review
expected bool
}{
{
name: "has body",
review: Review{Body: "Good work!"},
expected: true,
},
{
name: "has comments",
review: Review{CommentsCount: 3},
expected: true,
},
{
name: "requests changes",
review: Review{State: ReviewChangesRequested},
expected: true,
},
{
name: "empty approval",
review: Review{State: ReviewApproved},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.review.IsSubstantive())
})
}
}
func TestIssue_IsClosed(t *testing.T) {
t.Parallel()
tests := []struct {
name string
state IssueState
expected bool
}{
{name: "open", state: IssueStateOpen, expected: false},
{name: "closed", state: IssueStateClosed, expected: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
issue := Issue{State: tt.state}
assert.Equal(t, tt.expected, issue.IsClosed())
})
}
}
func TestIssue_CalculateTimeToClose(t *testing.T) {
t.Parallel()
t.Run("returns duration when closed", func(t *testing.T) {
t.Parallel()
created := time.Date(2024, 1, 1, 10, 0, 0, 0, time.UTC)
closed := time.Date(2024, 1, 3, 10, 0, 0, 0, time.UTC)
issue := Issue{CreatedAt: created, ClosedAt: &closed}
result := issue.CalculateTimeToClose()
assert.NotNil(t, result)
assert.Equal(t, 48*time.Hour, *result)
})
t.Run("returns nil when not closed", func(t *testing.T) {
t.Parallel()
issue := Issue{CreatedAt: time.Now()}
assert.Nil(t, issue.CalculateTimeToClose())
})
}
func TestPullRequest_TotalChanges(t *testing.T) {
t.Parallel()
pr := PullRequest{Additions: 200, Deletions: 100}
assert.Equal(t, 300, pr.TotalChanges())
}
+107
View File
@@ -0,0 +1,107 @@
package models
import "time"
// PRState represents the state of a pull request
type PRState string
const (
PRStateOpen PRState = "open"
PRStateClosed PRState = "closed"
PRStateMerged PRState = "merged"
)
// PullRequest represents a GitHub pull request
type PullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
State PRState `json:"state"`
Author Author `json:"author"`
Repository string `json:"repository"` // owner/repo format
BaseBranch string `json:"base_branch"` // Target branch (e.g., main, master)
HeadBranch string `json:"head_branch"` // Source branch
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MergedAt *time.Time `json:"merged_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
FilesChanged int `json:"files_changed"`
CommitCount int `json:"commit_count"`
Comments int `json:"comments"`
Reviews []Review `json:"reviews,omitempty"`
URL string `json:"url"`
// Derived fields
TimeToMerge *time.Duration `json:"time_to_merge,omitempty"`
TimeToFirstReview *time.Duration `json:"time_to_first_review,omitempty"`
}
// IsMerged returns true if the PR has been merged
func (pr *PullRequest) IsMerged() bool {
return pr.State == PRStateMerged || pr.MergedAt != nil
}
// TotalChanges returns the total lines changed (additions + deletions)
func (pr *PullRequest) TotalChanges() int {
return pr.Additions + pr.Deletions
}
// CalculateTimeToMerge calculates the time from PR creation to merge
func (pr *PullRequest) CalculateTimeToMerge() *time.Duration {
if pr.MergedAt == nil {
return nil
}
d := pr.MergedAt.Sub(pr.CreatedAt)
return &d
}
// CalculateTimeToFirstReview calculates the time from PR creation to first review
func (pr *PullRequest) CalculateTimeToFirstReview() *time.Duration {
if len(pr.Reviews) == 0 {
return nil
}
var firstReview *time.Time
for _, review := range pr.Reviews {
if firstReview == nil || review.SubmittedAt.Before(*firstReview) {
t := review.SubmittedAt
firstReview = &t
}
}
if firstReview == nil {
return nil
}
d := firstReview.Sub(pr.CreatedAt)
return &d
}
// PRSize represents the size category of a pull request
type PRSize string
const (
PRSizeXS PRSize = "xs" // < 10 lines
PRSizeS PRSize = "s" // 10-50 lines
PRSizeM PRSize = "m" // 50-200 lines
PRSizeL PRSize = "l" // 200-500 lines
PRSizeXL PRSize = "xl" // > 500 lines
)
// Size returns the size category of the PR based on total changes
func (pr *PullRequest) Size() PRSize {
total := pr.TotalChanges()
switch {
case total < 10:
return PRSizeXS
case total < 50:
return PRSizeS
case total < 200:
return PRSizeM
case total < 500:
return PRSizeL
default:
return PRSizeXL
}
}
+9
View File
@@ -0,0 +1,9 @@
package models
// RawData holds the raw collected data from GitHub
type RawData struct {
Commits []Commit
PullRequests []PullRequest
Reviews []Review
Issues []Issue
}
+57
View File
@@ -0,0 +1,57 @@
package models
import "time"
// ReviewState represents the state of a review
type ReviewState string
const (
ReviewApproved ReviewState = "APPROVED"
ReviewChangesRequested ReviewState = "CHANGES_REQUESTED"
ReviewCommented ReviewState = "COMMENTED"
ReviewPending ReviewState = "PENDING"
ReviewDismissed ReviewState = "DISMISSED"
)
// Review represents a GitHub pull request review
type Review struct {
ID int64 `json:"id"`
PullRequest int `json:"pull_request"`
Repository string `json:"repository"` // owner/repo format
Author Author `json:"author"`
State ReviewState `json:"state"`
SubmittedAt time.Time `json:"submitted_at"`
Body string `json:"body,omitempty"`
CommentsCount int `json:"comments_count"`
// Derived fields
ResponseTime *time.Duration `json:"response_time,omitempty"` // Time from PR creation or review request to review
}
// IsApproval returns true if the review is an approval
func (r *Review) IsApproval() bool {
return r.State == ReviewApproved
}
// RequestsChanges returns true if the review requests changes
func (r *Review) RequestsChanges() bool {
return r.State == ReviewChangesRequested
}
// IsSubstantive returns true if the review has meaningful content (not just a simple approval)
func (r *Review) IsSubstantive() bool {
return r.Body != "" || r.CommentsCount > 0 || r.State == ReviewChangesRequested
}
// ReviewComment represents a comment on a pull request review
type ReviewComment struct {
ID int64 `json:"id"`
ReviewID int64 `json:"review_id"`
PullRequest int `json:"pull_request"`
Repository string `json:"repository"`
Author Author `json:"author"`
Body string `json:"body"`
Path string `json:"path,omitempty"`
Line int `json:"line,omitempty"`
CreatedAt time.Time `json:"created_at"`
}