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
+182
View File
@@ -0,0 +1,182 @@
package site
import (
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
json "github.com/goccy/go-json"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
//go:embed dist/*
var spaFS embed.FS
// Generator handles static site generation
type Generator struct {
outputDir string
config *config.Config
}
// NewGenerator creates a new site generator
func NewGenerator(outputDir string, cfg *config.Config) (*Generator, error) {
return &Generator{
outputDir: outputDir,
config: cfg,
}, nil
}
// Generate creates the static site from metrics
func (g *Generator) Generate(metrics *models.GlobalMetrics) error {
// Create output directory
if err := os.MkdirAll(g.outputDir, 0750); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Generate data files
if err := g.generateDataFiles(metrics); err != nil {
return fmt.Errorf("failed to generate data files: %w", err)
}
// Copy Vue SPA files
if err := g.copySPAFiles(); err != nil {
return fmt.Errorf("failed to copy SPA files: %w", err)
}
return nil
}
func (g *Generator) generateDataFiles(metrics *models.GlobalMetrics) error {
dataDir := filepath.Join(g.outputDir, "data")
// Clean old data directory to ensure fresh state
if err := os.RemoveAll(dataDir); err != nil {
return fmt.Errorf("failed to clean data directory: %w", err)
}
if err := os.MkdirAll(dataDir, 0750); err != nil {
return err
}
// Prepare global data with timestamp
globalData := struct {
*models.GlobalMetrics
GeneratedAt time.Time `json:"generated_at"`
}{
GlobalMetrics: metrics,
GeneratedAt: time.Now(),
}
// Global metrics
if err := writeJSON(filepath.Join(dataDir, "global.json"), globalData); err != nil {
return err
}
// Leaderboard
if err := writeJSON(filepath.Join(dataDir, "leaderboard.json"), metrics.Leaderboard); err != nil {
return err
}
// Per-repository data
for _, repo := range metrics.Repositories {
repoDir := filepath.Join(dataDir, "repos", repo.Owner, repo.Name)
if err := os.MkdirAll(repoDir, 0750); err != nil {
return err
}
if err := writeJSON(filepath.Join(repoDir, "metrics.json"), repo); err != nil {
return err
}
}
// Per-team data
if len(metrics.Teams) > 0 {
teamDir := filepath.Join(dataDir, "teams")
if err := os.MkdirAll(teamDir, 0750); err != nil {
return err
}
for _, team := range metrics.Teams {
if err := writeJSON(filepath.Join(teamDir, slugify(team.Name)+".json"), team); err != nil {
return err
}
}
}
// Per-contributor data
contributorsSeen := make(map[string]bool)
contributorDir := filepath.Join(dataDir, "contributors")
if err := os.MkdirAll(contributorDir, 0750); err != nil {
return err
}
for _, repo := range metrics.Repositories {
for _, contributor := range repo.Contributors {
if contributorsSeen[contributor.Login] {
continue
}
contributorsSeen[contributor.Login] = true
if err := writeJSON(filepath.Join(contributorDir, contributor.Login+".json"), contributor); err != nil {
return err
}
}
}
return nil
}
func (g *Generator) copySPAFiles() error {
return fs.WalkDir(spaFS, "dist", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root dist directory itself
if path == "dist" {
return nil
}
// Calculate the relative path from "dist/"
relPath := strings.TrimPrefix(path, "dist/")
destPath := filepath.Join(g.outputDir, relPath)
if d.IsDir() {
return os.MkdirAll(destPath, 0750)
}
// Read file from embedded FS
content, err := spaFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read embedded file %s: %w", path, err)
}
// Write to destination
return os.WriteFile(destPath, content, 0600)
})
}
// Helper functions
func writeJSON(path string, data interface{}) error {
cleanPath := filepath.Clean(path)
file, err := os.OpenFile(cleanPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) // #nosec G304 -- path is constructed internally
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
return encoder.Encode(data)
}
func slugify(s string) string {
s = strings.ToLower(s)
s = strings.ReplaceAll(s, " ", "-")
s = strings.ReplaceAll(s, "_", "-")
return s
}
+502
View File
@@ -0,0 +1,502 @@
package site
import (
"os"
"path/filepath"
"testing"
"time"
json "github.com/goccy/go-json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
func TestNewGenerator(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
gen, err := NewGenerator("/tmp/output", cfg)
require.NoError(t, err)
assert.NotNil(t, gen)
assert.Equal(t, "/tmp/output", gen.outputDir)
assert.Equal(t, cfg, gen.config)
}
func TestGenerator_GenerateCreatesOutputDir(t *testing.T) {
tempDir := t.TempDir()
outputDir := filepath.Join(tempDir, "new-output")
cfg := config.DefaultConfig()
gen, err := NewGenerator(outputDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Period: models.Period{
Start: time.Now().Add(-24 * time.Hour),
End: time.Now(),
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify output directory was created
info, err := os.Stat(outputDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestGenerator_GenerateCreatesDataDir(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify data directory was created
dataDir := filepath.Join(tempDir, "data")
info, err := os.Stat(dataDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
}
func TestGenerator_GenerateGlobalJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
TotalContributors: 5,
TotalCommits: 100,
TotalPRs: 50,
TotalReviews: 75,
TotalLinesAdded: 10000,
TotalLinesDeleted: 5000,
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify global.json
globalPath := filepath.Join(tempDir, "data", "global.json")
data, err := os.ReadFile(globalPath)
require.NoError(t, err)
var result struct {
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"`
GeneratedAt time.Time `json:"GeneratedAt"`
}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, 5, result.TotalContributors)
assert.Equal(t, 100, result.TotalCommits)
assert.Equal(t, 50, result.TotalPRs)
assert.Equal(t, 75, result.TotalReviews)
assert.Equal(t, 10000, result.TotalLinesAdded)
assert.Equal(t, 5000, result.TotalLinesDeleted)
assert.False(t, result.GeneratedAt.IsZero())
}
func TestGenerator_GenerateLeaderboardJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Leaderboard: []models.LeaderboardEntry{
{Rank: 1, Login: "user1", Score: 1000},
{Rank: 2, Login: "user2", Score: 800},
{Rank: 3, Login: "user3", Score: 600},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify leaderboard.json
leaderboardPath := filepath.Join(tempDir, "data", "leaderboard.json")
data, err := os.ReadFile(leaderboardPath)
require.NoError(t, err)
var result []models.LeaderboardEntry
err = json.Unmarshal(data, &result)
require.NoError(t, err)
require.Len(t, result, 3)
assert.Equal(t, "user1", result[0].Login)
assert.Equal(t, 1000, result[0].Score)
assert.Equal(t, "user2", result[1].Login)
assert.Equal(t, 800, result[1].Score)
}
func TestGenerator_GenerateRepositoryJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Owner: "myorg",
Name: "myrepo",
TotalCommits: 42,
TotalPRs: 10,
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify repository metrics
repoPath := filepath.Join(tempDir, "data", "repos", "myorg", "myrepo", "metrics.json")
data, err := os.ReadFile(repoPath)
require.NoError(t, err)
var result models.RepositoryMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "myorg", result.Owner)
assert.Equal(t, "myrepo", result.Name)
assert.Equal(t, 42, result.TotalCommits)
assert.Equal(t, 10, result.TotalPRs)
}
func TestGenerator_GenerateMultipleRepositories(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{Owner: "org1", Name: "repo1", TotalCommits: 100},
{Owner: "org1", Name: "repo2", TotalCommits: 200},
{Owner: "org2", Name: "repo3", TotalCommits: 300},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify all repository files exist
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org1", "repo1", "metrics.json"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org1", "repo2", "metrics.json"))
assert.NoError(t, err)
_, err = os.Stat(filepath.Join(tempDir, "data", "repos", "org2", "repo3", "metrics.json"))
assert.NoError(t, err)
}
func TestGenerator_GenerateTeamJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Teams: []models.TeamMetrics{
{
Name: "Backend Team",
Color: "#ff0000",
Members: []string{"user1", "user2"},
TotalScore: 1500,
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify team JSON (slugified name)
teamPath := filepath.Join(tempDir, "data", "teams", "backend-team.json")
data, err := os.ReadFile(teamPath)
require.NoError(t, err)
var result models.TeamMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "Backend Team", result.Name)
assert.Equal(t, "#ff0000", result.Color)
assert.Equal(t, 1500, result.TotalScore)
assert.Len(t, result.Members, 2)
}
func TestGenerator_GenerateContributorJSON(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{
Login: "john-doe",
Name: "John Doe",
CommitCount: 50,
PRsOpened: 10,
},
},
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Read and verify contributor JSON
contributorPath := filepath.Join(tempDir, "data", "contributors", "john-doe.json")
data, err := os.ReadFile(contributorPath)
require.NoError(t, err)
var result models.ContributorMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "john-doe", result.Login)
assert.Equal(t, "John Doe", result.Name)
assert.Equal(t, 50, result.CommitCount)
assert.Equal(t, 10, result.PRsOpened)
}
func TestGenerator_ContributorDeduplication(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
// Same contributor in multiple repos
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50},
},
},
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 75}, // Same user, different count
},
},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Should only have one contributor file (first one seen)
contributorPath := filepath.Join(tempDir, "data", "contributors", "user1.json")
data, err := os.ReadFile(contributorPath)
require.NoError(t, err)
var result models.ContributorMetrics
err = json.Unmarshal(data, &result)
require.NoError(t, err)
// Should be the first one (50 commits)
assert.Equal(t, 50, result.CommitCount)
}
func TestGenerator_NoTeamsDoesNotCreateTeamDir(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
metrics := &models.GlobalMetrics{
Teams: []models.TeamMetrics{}, // Empty teams
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Team directory should not exist
teamDir := filepath.Join(tempDir, "data", "teams")
_, err = os.Stat(teamDir)
assert.True(t, os.IsNotExist(err))
}
func TestSlugify(t *testing.T) {
t.Parallel()
tests := []struct {
input string
expected string
}{
{"Backend Team", "backend-team"},
{"Frontend_Team", "frontend-team"},
{"UPPER CASE", "upper-case"},
{"already-slug", "already-slug"},
{"Multiple Spaces", "multiple---spaces"},
{"Mixed_And Spaced", "mixed-and-spaced"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
result := slugify(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestWriteJSON(t *testing.T) {
tempDir := t.TempDir()
testData := map[string]interface{}{
"key": "value",
"number": 42,
"nested": map[string]string{
"inner": "data",
},
}
path := filepath.Join(tempDir, "test.json")
err := writeJSON(path, testData)
require.NoError(t, err)
// Verify file was created and is valid JSON
data, err := os.ReadFile(path)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(data, &result)
require.NoError(t, err)
assert.Equal(t, "value", result["key"])
assert.Equal(t, float64(42), result["number"]) // JSON numbers are float64
}
func TestWriteJSON_Indented(t *testing.T) {
tempDir := t.TempDir()
testData := map[string]string{"key": "value"}
path := filepath.Join(tempDir, "test.json")
err := writeJSON(path, testData)
require.NoError(t, err)
data, err := os.ReadFile(path)
require.NoError(t, err)
// Should be formatted with indentation
assert.Contains(t, string(data), "\n")
assert.Contains(t, string(data), " ") // 2-space indent
}
func TestWriteJSON_ErrorOnInvalidPath(t *testing.T) {
// Try to write to a path that doesn't exist
path := "/nonexistent/directory/test.json"
err := writeJSON(path, "data")
assert.Error(t, err)
}
func TestGenerator_GenerateWithFullMetrics(t *testing.T) {
tempDir := t.TempDir()
cfg := config.DefaultConfig()
gen, err := NewGenerator(tempDir, cfg)
require.NoError(t, err)
// Create comprehensive metrics
metrics := &models.GlobalMetrics{
Period: models.Period{
Start: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
End: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC),
Granularity: "monthly",
Label: "2024",
},
TotalContributors: 10,
TotalCommits: 500,
TotalPRs: 100,
TotalReviews: 200,
TotalLinesAdded: 50000,
TotalLinesDeleted: 25000,
Repositories: []models.RepositoryMetrics{
{
Owner: "org",
Name: "repo1",
TotalCommits: 300,
TotalPRs: 60,
ActiveContributors: 5,
Contributors: []models.ContributorMetrics{
{Login: "alice", Name: "Alice", CommitCount: 100},
{Login: "bob", Name: "Bob", CommitCount: 200},
},
},
{
Owner: "org",
Name: "repo2",
TotalCommits: 200,
TotalPRs: 40,
ActiveContributors: 5,
Contributors: []models.ContributorMetrics{
{Login: "alice", Name: "Alice", CommitCount: 50},
{Login: "charlie", Name: "Charlie", CommitCount: 150},
},
},
},
Teams: []models.TeamMetrics{
{
Name: "Core Team",
Members: []string{"alice", "bob"},
TotalScore: 5000,
},
},
Leaderboard: []models.LeaderboardEntry{
{Rank: 1, Login: "alice", Score: 3000},
{Rank: 2, Login: "bob", Score: 2000},
{Rank: 3, Login: "charlie", Score: 1500},
},
}
err = gen.Generate(metrics)
require.NoError(t, err)
// Verify all expected files exist
expectedPaths := []string{
filepath.Join(tempDir, "data", "global.json"),
filepath.Join(tempDir, "data", "leaderboard.json"),
filepath.Join(tempDir, "data", "repos", "org", "repo1", "metrics.json"),
filepath.Join(tempDir, "data", "repos", "org", "repo2", "metrics.json"),
filepath.Join(tempDir, "data", "teams", "core-team.json"),
filepath.Join(tempDir, "data", "contributors", "alice.json"),
filepath.Join(tempDir, "data", "contributors", "bob.json"),
filepath.Join(tempDir, "data", "contributors", "charlie.json"),
}
for _, path := range expectedPaths {
_, err := os.Stat(path)
assert.NoError(t, err, "Expected file to exist: %s", path)
}
}