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
@@ -0,0 +1,29 @@
name: Git Velocity Analysis
on:
schedule:
# Run weekly on Monday at 00:00 UTC
- cron: '0 0 * * 1'
workflow_dispatch: # Allow manual trigger
jobs:
analyze:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run Git Velocity Analysis
uses: lukaszraczylo/git-velocity/.github/actions/git-velocity@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
config_file: '.git-velocity.yaml'
output_dir: './dist'
deploy_gh_pages: 'true'
upload_artifact: 'true'
artifact_name: 'velocity-dashboard'
+52
View File
@@ -0,0 +1,52 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build Vue SPA
working-directory: web
run: |
npm ci
npm run build
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+9
View File
@@ -0,0 +1,9 @@
node_modules/
git-velocity
.repos/
.cache/
dist/
web/dist/
web/public/data
config.yaml
.claude
+92
View File
@@ -0,0 +1,92 @@
version: 2
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- id: git-velocity
main: ./cmd/git-velocity
binary: git-velocity
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X github.com/lukaszraczylo/git-velocity/pkg/version.Version={{.Version}}
- -X github.com/lukaszraczylo/git-velocity/pkg/version.Commit={{.Commit}}
- -X github.com/lukaszraczylo/git-velocity/pkg/version.BuildDate={{.Date}}
archives:
- id: git-velocity
formats: [tar.gz]
name_template: "git-velocity-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats: [zip]
files:
- LICENSE
- README.md
- config.example.yaml
checksum:
name_template: "git-velocity-checksums.txt"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^Merge'
- '^WIP'
- '^Update go.mod'
- '^chore:'
release:
github:
owner: lukaszraczylo
name: git-velocity
name_template: "v{{.Version}}"
draft: false
prerelease: auto
dockers_v2:
- images:
- "ghcr.io/lukaszraczylo/git-velocity"
tags:
- "{{ .Version }}"
- "latest"
- "v1"
platforms:
- linux/amd64
- linux/arm64
dockerfile: Dockerfile
extra_files:
- config.example.yaml
homebrew_casks:
- name: git-velocity
repository:
owner: lukaszraczylo
name: homebrew-taps
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
directory: Casks
homepage: https://github.com/lukaszraczylo/git-velocity
description: "Developer velocity metrics analyzer with gamification dashboards"
license: MIT
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr",
args: ["-dr", "com.apple.quarantine", "#{staged_path}/git-velocity"]
end
+11
View File
@@ -0,0 +1,11 @@
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata git
COPY git-velocity /usr/local/bin/git-velocity
COPY config.example.yaml /etc/git-velocity/config.example.yaml
RUN chmod +x /usr/local/bin/git-velocity
ENTRYPOINT ["/usr/local/bin/git-velocity"]
CMD ["--help"]
+102
View File
@@ -0,0 +1,102 @@
.PHONY: all build build-spa build-quick install clean test test-coverage lint dev dev-spa serve help
# Build configuration
BINARY_NAME := git-velocity
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -X github.com/lukaszraczylo/git-velocity/pkg/version.Version=$(VERSION) \
-X github.com/lukaszraczylo/git-velocity/pkg/version.BuildTime=$(BUILD_TIME)
# Directories
WEB_DIR := web
DIST_DIR := $(WEB_DIR)/dist
EMBED_DIR := internal/generator/site/dist
all: build
## Build the Vue SPA
build-spa:
@echo "Building Vue SPA..."
@rm -f $(WEB_DIR)/public/data # Remove dev symlink if exists (breaks vite build)
@cd $(WEB_DIR) && npm install && npm run build
@rm -rf $(EMBED_DIR)
@mkdir -p $(EMBED_DIR)
@cp -r $(DIST_DIR)/* $(EMBED_DIR)/
@echo "SPA built and copied to $(EMBED_DIR)"
## Build the Go binary (requires SPA to be built first)
build: build-spa
@echo "Building Go binary..."
@go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) ./cmd/git-velocity
@echo "Built $(BINARY_NAME)"
## Build without rebuilding SPA (faster for Go-only changes)
build-quick:
@echo "Building Go binary (quick)..."
@go build -ldflags "$(LDFLAGS)" -o $(BINARY_NAME) ./cmd/git-velocity
@echo "Built $(BINARY_NAME)"
## Install the binary
install: build
@go install -ldflags "$(LDFLAGS)" ./cmd/git-velocity
## Run tests
test:
@echo "Running tests..."
@go test -race -v ./...
## Run tests with coverage
test-coverage:
@echo "Running tests with coverage..."
@go test -race -coverprofile=coverage.out ./...
@go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
## Run linter
lint:
@echo "Running linter..."
@golangci-lint run ./...
## Run Vue dev server for frontend development
dev-spa:
@mkdir -p ./dist/data # Ensure data dir exists for symlink
@test -L $(WEB_DIR)/public/data || ln -sf ../../dist/data $(WEB_DIR)/public/data
@cd $(WEB_DIR) && npm run dev
## Run Go binary with sample config
dev:
@go run ./cmd/git-velocity analyze --config config.example.yaml --output ./dist
## Serve generated output
serve:
@rm -f ./dist/index.html
@rm -rf ./dist/assets
@cp -r $(EMBED_DIR)/* ./dist/
@go run ./cmd/git-velocity serve --directory ./dist --port 8080
## Clean build artifacts
clean:
@rm -rf $(BINARY_NAME) $(DIST_DIR) $(EMBED_DIR) coverage.out coverage.html dist
@echo "Cleaned build artifacts"
## Show help
help:
@echo "Git Velocity - Developer Metrics Dashboard"
@echo ""
@echo "Usage:"
@echo " make [target]"
@echo ""
@echo "Targets:"
@echo " all Build everything (default)"
@echo " build Build Vue SPA and Go binary"
@echo " build-spa Build only the Vue SPA"
@echo " build-quick Build Go binary without rebuilding SPA"
@echo " install Install binary to GOPATH/bin"
@echo " test Run tests with race detector"
@echo " test-coverage Run tests with coverage report"
@echo " lint Run golangci-lint"
@echo " dev-spa Run Vue dev server"
@echo " dev Run analyzer with sample config"
@echo " serve Serve generated output locally"
@echo " clean Remove build artifacts"
@echo " help Show this help"
+136
View File
@@ -0,0 +1,136 @@
# Git Velocity Configuration Example
# Copy this file to .git-velocity.yaml and customize for your needs
version: "1.0"
# Authentication (one method required)
auth:
# Option 1: Personal Access Token (simplest)
github_token: "${GITHUB_TOKEN}"
# Option 2: GitHub App (for organizations/enterprises)
# github_app:
# app_id: 123456
# installation_id: 12345678
# private_key_path: "/path/to/private-key.pem"
# # OR inline base64-encoded key:
# # private_key: "base64-encoded-private-key"
# Repositories to analyze
repositories:
# Explicit repository
- owner: "your-org"
name: "your-repo"
# Multiple repos
- owner: "your-org"
name: "another-repo"
# Pattern matching (all repos in org matching pattern)
# - owner: "your-org"
# pattern: "backend-*"
# Date range for analysis (optional)
# Supports both absolute dates and relative dates
date_range:
# Absolute date format: YYYY-MM-DD
# start: "2024-01-01"
# Relative date formats:
# -Nd = N days ago (e.g., -90d = 90 days ago)
# -Nw = N weeks ago (e.g., -2w = 2 weeks ago)
# -Nm = N months ago (e.g., -3m = 3 months ago)
# -Ny = N years ago (e.g., -1y = 1 year ago)
start: "-90d" # Analyze last 90 days
end: "" # Empty = now
# Time granularity for metrics aggregation
granularity:
- daily
- weekly
- monthly
# Custom time periods (optional)
# custom_periods:
# - name: "Q1 2024"
# start: "2024-01-01"
# end: "2024-03-31"
# - name: "Q2 2024"
# start: "2024-04-01"
# end: "2024-06-30"
# Team definitions (optional)
teams:
- name: "Backend Team"
members:
- "dev1"
- "dev2"
- "dev3"
color: "#3B82F6" # Blue
- name: "Frontend Team"
members:
- "dev4"
- "dev5"
color: "#10B981" # Green
# - name: "DevOps Team"
# members:
# - "devops1"
# color: "#F59E0B" # Yellow
# Gamification scoring configuration
scoring:
enabled: true
# Point values for different activities
points:
commit: 10
commit_with_tests: 15
lines_added: 0.1
lines_deleted: 0.05
pr_opened: 25
pr_merged: 50
pr_reviewed: 30
review_comment: 5
issue_opened: 15
issue_closed: 20
fast_review_1h: 50 # Review response under 1 hour
fast_review_4h: 25 # Review response under 4 hours
fast_review_24h: 10 # Review response under 24 hours
# Achievement badges (optional, uses defaults if not specified)
# achievements:
# - id: "custom-achievement"
# name: "Custom Badge"
# description: "Earned for custom condition"
# icon: "fa-star"
# condition:
# type: "commit_count" # commit_count, pr_opened_count, review_count, etc.
# threshold: 100
# Output configuration
output:
directory: "./dist"
format:
- html
- json
deploy:
gh_pages: true
artifact: true
# Caching configuration
cache:
enabled: true
directory: "./.cache"
ttl: "24h"
# Advanced options
options:
concurrent_requests: 5 # Max parallel API requests (1-20)
include_bots: false # Include bot accounts in metrics
bot_patterns: # Patterns to identify bot accounts
- "*[bot]"
- "dependabot*"
- "renovate*"
- "github-actions*"
+500
View File
@@ -0,0 +1,500 @@
<!doctype html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Git Velocity - Developer Metrics & Gamification Dashboard</title>
<meta
name="description"
content="Analyze Git repositories for developer velocity metrics with gamification. Generate beautiful dashboards with leaderboards, achievements, and team insights."
/>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class'
}
</script>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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"
/>
<style>
body { font-family: "Inter", sans-serif; }
code, pre { font-family: "JetBrains Mono", monospace; }
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.animate-fade-in-up { animation: fadeInUp 0.6s ease-out; }
.animate-float { animation: float 3s ease-in-out infinite; }
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(17, 24, 39, 0.7);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.gradient-text {
background: linear-gradient(135deg, #f472b6 0%, #c084fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dark .gradient-text {
background: linear-gradient(135deg, #f9a8d4 0%, #d8b4fe 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.1); }
.dark .shadow-modern { box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4); }
html { scroll-behavior: smooth; }
</style>
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 theme-transition">
<!-- Navigation -->
<nav class="fixed w-full glass shadow-modern z-50 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex justify-between h-16 items-center">
<a href="#" class="flex items-center hover:opacity-80 transition-opacity duration-300 gap-2">
<i class="fas fa-rocket text-2xl gradient-text"></i>
<span class="text-xl font-bold gradient-text">git-velocity</span>
</a>
<div class="hidden md:flex space-x-6">
<a href="#features" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Features</a>
<a href="#installation" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Installation</a>
<a href="#usage" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Usage</a>
<a href="#configuration" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 font-medium">Configuration</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle theme">
<i class="fas fa-moon dark:hidden text-xl"></i>
<i class="fas fa-sun hidden dark:inline text-xl"></i>
</button>
<a href="https://github.com/lukaszraczylo/git-velocity" target="_blank" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="View on GitHub">
<i class="fab fa-github text-xl"></i>
</a>
<button id="mobile-menu-toggle" class="md:hidden text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 p-2 min-w-[44px] min-h-[44px] flex items-center justify-center" aria-label="Toggle menu">
<i class="fas fa-bars text-xl" id="menu-open-icon"></i>
<i class="fas fa-times text-xl hidden" id="menu-close-icon"></i>
</button>
</div>
</div>
</div>
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 space-y-1 bg-white dark:bg-gray-800">
<a href="#features" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Features</a>
<a href="#installation" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Installation</a>
<a href="#usage" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Usage</a>
<a href="#configuration" class="block px-3 py-3 text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 rounded font-medium">Configuration</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="relative pt-24 sm:pt-32 pb-12 sm:pb-20 overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-pink-50 via-purple-50 to-indigo-50 dark:from-gray-900 dark:via-pink-900/20 dark:to-purple-900/20 theme-transition"></div>
<div class="absolute top-0 -left-4 w-72 h-72 bg-pink-300 dark:bg-pink-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float"></div>
<div class="absolute top-0 -right-4 w-72 h-72 bg-purple-300 dark:bg-purple-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 1s;"></div>
<div class="absolute -bottom-8 left-20 w-72 h-72 bg-indigo-300 dark:bg-indigo-500 rounded-full mix-blend-multiply dark:mix-blend-soft-light filter blur-xl opacity-20 animate-float" style="animation-delay: 2s;"></div>
<div class="relative max-w-6xl mx-auto px-4 sm:px-6">
<div class="text-center">
<div class="mb-8 sm:mb-10 flex justify-center animate-fade-in-up">
<div class="text-8xl sm:text-9xl animate-float">
<i class="fas fa-rocket gradient-text"></i>
</div>
</div>
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6 leading-tight animate-fade-in-up" style="animation-delay: 0.1s;">
Developer Velocity<br /><span class="gradient-text">Dashboard</span>
</h1>
<p class="text-base sm:text-lg md:text-xl text-gray-600 dark:text-gray-300 mb-8 sm:mb-10 max-w-2xl mx-auto leading-relaxed px-4 animate-fade-in-up" style="animation-delay: 0.2s;">
Analyze GitHub repositories for developer metrics with gamification. Generate beautiful dashboards with leaderboards, achievements, and team insights.
</p>
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4 justify-center mb-8 sm:mb-12 px-4 animate-fade-in-up" style="animation-delay: 0.3s;">
<a href="#installation" class="group relative bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-105">
<span class="relative z-10">Get Started</span>
</a>
<a href="https://github.com/lukaszraczylo/git-velocity" class="group glass hover:shadow-lg text-gray-900 dark:text-gray-100 px-8 py-3 rounded-lg font-medium transition-all duration-300 min-h-[48px] flex items-center justify-center hover:scale-105">
<i class="fab fa-github mr-2"></i>View on GitHub
</a>
</div>
<div class="flex flex-wrap justify-center gap-2 sm:gap-4 text-sm px-4">
<img src="https://img.shields.io/github/v/release/lukaszraczylo/git-velocity" alt="Version" class="h-5" />
<img src="https://img.shields.io/github/license/lukaszraczylo/git-velocity" alt="License" class="h-5" />
<img src="https://goreportcard.com/badge/github.com/lukaszraczylo/git-velocity" alt="Go Report" class="h-5" />
</div>
<div class="mt-12 sm:mt-16 max-w-3xl mx-auto px-4 animate-fade-in-up" style="animation-delay: 0.4s;">
<div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-pink-500 to-purple-600 rounded-xl blur opacity-25 group-hover:opacity-50 transition duration-500"></div>
<div class="relative bg-gray-900 rounded-xl p-6 text-left">
<div class="flex items-center gap-2 mb-4">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="ml-2 text-gray-400 text-sm">terminal</span>
</div>
<pre class="text-gray-100 text-sm sm:text-base overflow-x-auto"><code><span class="text-gray-400">$</span> git-velocity analyze --config config.yaml
<span class="text-pink-400">Fetching data from GitHub...</span>
<span class="text-purple-400">Processing 3 repositories...</span>
<span class="text-green-400">Generated dashboard at ./dist</span>
<span class="text-gray-400">$</span> git-velocity serve --port 8080
<span class="text-green-400">Starting preview server at http://localhost:8080</span></code></pre>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="features" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<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">Features</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Everything you need to track developer productivity</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-pink-500 to-pink-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-chart-line text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Velocity Metrics</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Track commits, PRs, reviews, and code changes over time</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-trophy text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Gamification</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Score points, earn achievements, and compete on leaderboards</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-indigo-500 to-indigo-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-users text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Team Insights</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Configure teams and see aggregated team metrics</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fab fa-github text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">GitHub Action</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Run analysis automatically in your CI/CD pipeline</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-file-code text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Static Site</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">Generate a beautiful Vue.js SPA dashboard</p>
</div>
</div>
</div>
<div class="glass p-5 rounded-xl group hover:shadow-lg transition-all duration-300">
<div class="flex items-start gap-4">
<div class="w-12 h-12 rounded-xl bg-gradient-to-br from-orange-500 to-orange-600 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<i class="fas fa-bolt text-white"></i>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-1">Fast & Cached</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">File-based caching and concurrent API requests</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Installation Section -->
<section id="installation" class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<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">Installation</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Get started in seconds</p>
</div>
<div class="max-w-3xl mx-auto space-y-6">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-download mr-2 text-pink-500"></i>
Go Install
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto"><code>go install github.com/lukaszraczylo/git-velocity/cmd/git-velocity@latest</code></pre>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fas fa-file-download mr-2 text-purple-500"></i>
Download Binary
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-3">Download from the <a href="https://github.com/lukaszraczylo/git-velocity/releases/latest" class="text-pink-600 dark:text-pink-400 hover:underline">releases page</a>.</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Supported: macOS, Linux, Windows (amd64, arm64)</p>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center">
<i class="fab fa-github mr-2 text-indigo-500"></i>
GitHub Action
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>- uses: lukaszraczylo/git-velocity/.github/actions/git-velocity@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
config_file: '.git-velocity.yaml'</code></pre>
</div>
</div>
</div>
</section>
<!-- Usage Section -->
<section id="usage" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<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">Usage</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Simple CLI commands</p>
</div>
<div class="max-w-4xl mx-auto space-y-8">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center text-xl">
<i class="fas fa-terminal mr-2 text-pink-500"></i>
CLI Commands
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto mb-4"><code><span class="text-gray-400"># Analyze repositories and generate dashboard</span>
git-velocity analyze --config config.yaml --output ./dist
<span class="text-gray-400"># Start local preview server</span>
git-velocity serve --directory ./dist --port 8080
<span class="text-gray-400"># Show version</span>
git-velocity version</code></pre>
<div class="grid sm:grid-cols-2 gap-4 text-sm">
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Analyze Flags</h4>
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
<li><code class="text-pink-600 dark:text-pink-400">-c, --config</code> Path to config file</li>
<li><code class="text-pink-600 dark:text-pink-400">-o, --output</code> Output directory</li>
<li><code class="text-pink-600 dark:text-pink-400">-v, --verbose</code> Verbose output</li>
</ul>
</div>
<div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Serve Flags</h4>
<ul class="space-y-1 text-gray-600 dark:text-gray-400">
<li><code class="text-pink-600 dark:text-pink-400">-d, --directory</code> Directory to serve</li>
<li><code class="text-pink-600 dark:text-pink-400">-p, --port</code> Port to listen on</li>
</ul>
</div>
</div>
</div>
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4 flex items-center text-xl">
<i class="fab fa-github mr-2 text-purple-500"></i>
GitHub Action Example
</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code>name: Git Velocity Analysis
on:
schedule:
- cron: '0 0 * * 1' # Weekly on Monday
workflow_dispatch:
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Git Velocity Analysis
uses: lukaszraczylo/git-velocity/.github/actions/git-velocity@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
config_file: '.git-velocity.yaml'
output_dir: './dist'
deploy_gh_pages: 'true'
upload_artifact: 'true'</code></pre>
</div>
</div>
</div>
</section>
<!-- Metrics Section -->
<section class="py-12 sm:py-16 md:py-20 bg-gray-50 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<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">What Gets Tracked</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Comprehensive developer activity metrics</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="grid sm:grid-cols-2 gap-4">
<div class="glass p-4 rounded-xl">
<div class="flex items-center gap-3 mb-2">
<i class="fas fa-code-commit text-pink-500"></i>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Commits</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total commits, lines added/deleted, files changed</p>
</div>
<div class="glass p-4 rounded-xl">
<div class="flex items-center gap-3 mb-2">
<i class="fas fa-code-pull-request text-purple-500"></i>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Pull Requests</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">PRs opened, merged, closed, average size</p>
</div>
<div class="glass p-4 rounded-xl">
<div class="flex items-center gap-3 mb-2">
<i class="fas fa-eye text-indigo-500"></i>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Code Reviews</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Reviews given, comments, approvals, response time</p>
</div>
<div class="glass p-4 rounded-xl">
<div class="flex items-center gap-3 mb-2">
<i class="fas fa-exclamation-circle text-green-500"></i>
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Issues</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">Issues opened, closed, comments</p>
</div>
</div>
</div>
</div>
</section>
<!-- Configuration Section -->
<section id="configuration" class="py-12 sm:py-16 md:py-20 bg-white dark:bg-gray-900 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<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">Configuration</h2>
<p class="text-base sm:text-lg text-gray-600 dark:text-gray-300 px-4">Customize everything via YAML</p>
</div>
<div class="max-w-3xl mx-auto">
<div class="glass p-6 rounded-xl">
<h3 class="font-semibold text-gray-900 dark:text-gray-100 mb-4">.git-velocity.yaml</h3>
<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm"><code><span class="text-pink-400">version:</span> "1.0"
<span class="text-pink-400">auth:</span>
<span class="text-purple-400">github_token:</span> "${GITHUB_TOKEN}"
<span class="text-pink-400">repositories:</span>
- <span class="text-purple-400">owner:</span> "your-org"
<span class="text-purple-400">name:</span> "your-repo"
<span class="text-pink-400">teams:</span>
- <span class="text-purple-400">name:</span> "Backend Team"
<span class="text-purple-400">members:</span> ["dev1", "dev2"]
<span class="text-purple-400">color:</span> "#3B82F6"
<span class="text-pink-400">scoring:</span>
<span class="text-purple-400">enabled:</span> true
<span class="text-purple-400">points:</span>
<span class="text-indigo-400">commit:</span> 10
<span class="text-indigo-400">pr_opened:</span> 25
<span class="text-indigo-400">pr_merged:</span> 50
<span class="text-indigo-400">pr_reviewed:</span> 30
<span class="text-pink-400">output:</span>
<span class="text-purple-400">directory:</span> "./dist"
<span class="text-purple-400">deploy:</span>
<span class="text-indigo-400">gh_pages:</span> true</code></pre>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-8 bg-gray-100 dark:bg-gray-800 theme-transition">
<div class="max-w-6xl mx-auto px-4 sm:px-6">
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-2">
<i class="fas fa-rocket text-xl gradient-text"></i>
<span class="font-semibold gradient-text">git-velocity</span>
</div>
<div class="flex items-center gap-6">
<a href="https://github.com/lukaszraczylo/git-velocity" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100">
<i class="fab fa-github text-xl"></i>
</a>
<a href="https://github.com/lukaszraczylo/git-velocity/issues" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Issues
</a>
<a href="https://github.com/lukaszraczylo/git-velocity/releases" class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 text-sm">
Releases
</a>
</div>
<p class="text-gray-500 dark:text-gray-400 text-sm">MIT License</p>
</div>
</div>
</footer>
<script>
// Theme toggle
document.getElementById('theme-toggle').addEventListener('click', function() {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
});
// Mobile menu toggle
document.getElementById('mobile-menu-toggle').addEventListener('click', function() {
const menu = document.getElementById('mobile-menu');
const openIcon = document.getElementById('menu-open-icon');
const closeIcon = document.getElementById('menu-close-icon');
menu.classList.toggle('hidden');
openIcon.classList.toggle('hidden');
closeIcon.classList.toggle('hidden');
});
// Close mobile menu when clicking a link
document.querySelectorAll('#mobile-menu a').forEach(link => {
link.addEventListener('click', () => {
document.getElementById('mobile-menu').classList.add('hidden');
document.getElementById('menu-open-icon').classList.remove('hidden');
document.getElementById('menu-close-icon').classList.add('hidden');
});
});
</script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
module github.com/lukaszraczylo/git-velocity
go 1.24.0
require (
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0
github.com/go-git/go-git/v5 v5.16.4
github.com/google/go-github/v68 v68.0.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/go-github/v75 v75.0.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
+128
View File
@@ -0,0 +1,128 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 h1:SmbUK/GxpAspRjSQbB6ARvH+ArzlNzTtHydNyXUQ6zg=
github.com/bradleyfalzon/ghinstallation/v2 v2.17.0/go.mod h1:vuD/xvJT9Y+ZVZRv4HQ42cMyPFIYqpc7AbB4Gvt/DlY=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s=
github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68=
github.com/google/go-github/v75 v75.0.0 h1:k7q8Bvg+W5KxRl9Tjq16a9XEgVY1pwuiG5sIL7435Ic=
github.com/google/go-github/v75 v75.0.0/go.mod h1:H3LUJEA1TCrzuUqtdAQniBNwuKiQIqdGKgBo1/M/uqI=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
File diff suppressed because it is too large Load Diff
+383
View File
@@ -0,0 +1,383 @@
package aggregator
import (
"testing"
"time"
"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 TestNew(t *testing.T) {
t.Parallel()
cfg := &config.Config{}
agg := New(cfg)
assert.NotNil(t, agg)
assert.Equal(t, cfg, agg.config)
}
func TestAggregator_AggregateEmptyData(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.NotNil(t, metrics)
assert.Equal(t, 0, metrics.TotalContributors)
assert.Equal(t, 0, metrics.TotalCommits)
assert.Equal(t, 0, metrics.TotalPRs)
assert.Equal(t, 0, metrics.TotalReviews)
}
func TestAggregator_AggregateCommits(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Message: "Test commit",
Author: models.Author{Login: "user1", Name: "User One"},
Date: time.Now(),
Additions: 100,
Deletions: 50,
FilesChanged: 5,
Repository: "owner/repo",
},
{
SHA: "def456",
Message: "Another commit",
Author: models.Author{Login: "user1", Name: "User One"},
Date: time.Now(),
Additions: 200,
Deletions: 75,
FilesChanged: 3,
Repository: "owner/repo",
},
{
SHA: "ghi789",
Message: "User2 commit",
Author: models.Author{Login: "user2", Name: "User Two"},
Date: time.Now(),
Additions: 50,
Deletions: 25,
FilesChanged: 2,
Repository: "owner/repo",
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Equal(t, 3, metrics.TotalCommits)
assert.Equal(t, 350, metrics.TotalLinesAdded)
assert.Equal(t, 150, metrics.TotalLinesDeleted)
// Check repository metrics
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
assert.Equal(t, "owner", repo.Owner)
assert.Equal(t, "repo", repo.Name)
assert.Equal(t, 3, repo.TotalCommits)
}
func TestAggregator_AggregatePullRequests(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
mergedAt := time.Now()
data := &models.RawData{
PullRequests: []models.PullRequest{
{
Number: 1,
Title: "PR 1",
State: models.PRStateMerged,
Author: models.Author{Login: "user1", Name: "User One"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-time.Hour),
MergedAt: &mergedAt,
Additions: 100,
Deletions: 50,
},
{
Number: 2,
Title: "PR 2",
State: models.PRStateOpen,
Author: models.Author{Login: "user2", Name: "User Two"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-30 * time.Minute),
Additions: 200,
Deletions: 75,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Equal(t, 2, metrics.TotalPRs)
// Check repository metrics
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
assert.Equal(t, 2, repo.TotalPRs)
}
func TestAggregator_AggregateReviews(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
PullRequests: []models.PullRequest{
{
Number: 1,
Title: "PR 1",
State: models.PRStateOpen,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now(),
},
},
Reviews: []models.Review{
{
ID: 1,
PullRequest: 1,
Repository: "owner/repo",
Author: models.Author{Login: "reviewer1"},
State: models.ReviewApproved,
SubmittedAt: time.Now(),
},
{
ID: 2,
PullRequest: 1,
Repository: "owner/repo",
Author: models.Author{Login: "reviewer2"},
State: models.ReviewChangesRequested,
SubmittedAt: time.Now(),
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 3, metrics.TotalContributors) // user1, reviewer1, reviewer2
assert.Equal(t, 2, metrics.TotalReviews)
}
func TestAggregator_AggregateIssues(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
closedAt := time.Now()
data := &models.RawData{
// Need a commit to create the repository
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
},
},
Issues: []models.Issue{
{
Number: 1,
Title: "Issue 1",
State: models.IssueStateOpen,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now(),
},
{
Number: 2,
Title: "Issue 2",
State: models.IssueStateClosed,
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
CreatedAt: time.Now().Add(-time.Hour),
ClosedAt: &closedAt,
ClosedBy: &models.Author{Login: "user1"},
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 1, metrics.TotalContributors)
// Find user1 in repository contributors
require.Len(t, metrics.Repositories, 1)
repo := metrics.Repositories[0]
require.Len(t, repo.Contributors, 1)
assert.Equal(t, 2, repo.Contributors[0].IssuesOpened)
assert.Equal(t, 1, repo.Contributors[0].IssuesClosed)
}
func TestAggregator_AggregateTeams(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
Color: "#ff0000",
},
}
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo",
Additions: 100,
Deletions: 50,
},
{
SHA: "def456",
Author: models.Author{Login: "user2"},
Repository: "owner/repo",
Additions: 200,
Deletions: 75,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
require.Len(t, metrics.Teams, 1)
team := metrics.Teams[0]
assert.Equal(t, "Backend Team", team.Name)
assert.Equal(t, "#ff0000", team.Color)
assert.Len(t, team.MemberMetrics, 2)
assert.Equal(t, 2, team.AggregatedMetrics.CommitCount)
}
func TestAggregator_DateRange(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)
data := &models.RawData{}
dateRange := &config.ParsedDateRange{
Start: &start,
End: &end,
}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, start, metrics.Period.Start)
assert.Equal(t, end, metrics.Period.End)
}
func TestAggregator_MultipleRepositories(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
agg := New(cfg)
data := &models.RawData{
Commits: []models.Commit{
{
SHA: "abc123",
Author: models.Author{Login: "user1"},
Repository: "owner/repo1",
Additions: 100,
},
{
SHA: "def456",
Author: models.Author{Login: "user1"},
Repository: "owner/repo2",
Additions: 200,
},
{
SHA: "ghi789",
Author: models.Author{Login: "user2"},
Repository: "owner/repo1",
Additions: 50,
},
},
}
dateRange := &config.ParsedDateRange{}
metrics, err := agg.Aggregate(data, dateRange)
require.NoError(t, err)
assert.Equal(t, 2, metrics.TotalContributors)
assert.Len(t, metrics.Repositories, 2)
}
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
func TestParseRepoName(t *testing.T) {
t.Parallel()
tests := []struct {
fullName string
expectedOwner string
expectedName string
}{
{"owner/repo", "owner", "repo"},
{"org/project-name", "org", "project-name"},
{"user/repo-with-dashes", "user", "repo-with-dashes"},
{"single", "single", ""},
}
for _, tt := range tests {
owner, name := parseRepoName(tt.fullName)
assert.Equal(t, tt.expectedOwner, owner, "owner mismatch for %s", tt.fullName)
assert.Equal(t, tt.expectedName, name, "name mismatch for %s", tt.fullName)
}
}
+326
View File
@@ -0,0 +1,326 @@
package app
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/lukaszraczylo/git-velocity/internal/aggregator"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/domain/scoring"
"github.com/lukaszraczylo/git-velocity/internal/generator/site"
"github.com/lukaszraczylo/git-velocity/internal/git"
"github.com/lukaszraczylo/git-velocity/internal/github"
)
// App is the main application orchestrator
type App struct {
config *config.Config
outputDir string
verbose bool
client *github.Client
gitRepo *git.Repository
}
// New creates a new application instance
func New(configPath, outputDir string, verbose bool) (*App, error) {
// Load configuration
cfg, err := config.Load(configPath)
if err != nil {
return nil, fmt.Errorf("failed to load configuration: %w", err)
}
return &App{
config: cfg,
outputDir: outputDir,
verbose: verbose,
}, nil
}
// Run executes the main application workflow
func (a *App) Run(ctx context.Context) error {
startTime := time.Now()
a.log("Starting Git Velocity analysis...")
// Initialize GitHub client
a.log("Initializing GitHub client...")
client, err := github.NewClient(ctx, a.config)
if err != nil {
return fmt.Errorf("failed to create GitHub client: %w", err)
}
a.client = client
// Set up progress callback
client.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
// Initialize local git repository manager if using local git
if a.config.Options.UseLocalGit {
a.log("Initializing local git repository manager...")
gitRepo, err := git.NewRepository(a.config.Options.CloneDirectory)
if err != nil {
return fmt.Errorf("failed to create git repository manager: %w", err)
}
gitRepo.SetProgressCallback(func(msg string) {
a.log("%s", msg)
})
a.gitRepo = gitRepo
}
// Parse date range
dateRange, err := a.config.GetParsedDateRange()
if err != nil {
return fmt.Errorf("failed to parse date range: %w", err)
}
// Collect data from all repositories
a.log("Fetching data from repositories...")
rawData, err := a.collectData(ctx, dateRange)
if err != nil {
return fmt.Errorf("failed to collect data: %w", err)
}
a.log("Collected %d commits, %d PRs, %d reviews, %d issues",
len(rawData.Commits), len(rawData.PullRequests), len(rawData.Reviews), len(rawData.Issues))
// Fetch user profiles for better deduplication
// This gets public emails and names from GitHub profiles to help match commit authors
a.log("Fetching user profiles for deduplication...")
userProfiles, err := a.fetchUserProfiles(ctx, rawData)
if err != nil {
a.log("Warning: failed to fetch some user profiles: %v", err)
// Continue anyway, deduplication will still work with other methods
}
a.log("Fetched %d user profiles", len(userProfiles))
// Aggregate metrics
a.log("Aggregating metrics...")
agg := aggregator.New(a.config)
agg.SetUserProfiles(userProfiles)
globalMetrics, err := agg.Aggregate(rawData, dateRange)
if err != nil {
return fmt.Errorf("failed to aggregate metrics: %w", err)
}
// Calculate scores
if a.config.Scoring.Enabled {
a.log("Calculating scores and achievements...")
scorer := scoring.NewCalculator(a.config)
globalMetrics = scorer.Calculate(globalMetrics)
}
// Generate the site
a.log("Generating static site...")
gen, err := site.NewGenerator(a.outputDir, a.config)
if err != nil {
return fmt.Errorf("failed to create site generator: %w", err)
}
if err := gen.Generate(globalMetrics); err != nil {
return fmt.Errorf("failed to generate site: %w", err)
}
duration := time.Since(startTime)
a.log("Analysis complete! Dashboard generated in %s", a.outputDir)
a.log("Total time: %s", duration.Round(time.Millisecond))
return nil
}
func (a *App) collectData(ctx context.Context, dateRange *config.ParsedDateRange) (*models.RawData, error) {
data := &models.RawData{}
for _, repo := range a.config.Repositories {
if repo.Pattern != "" {
// Pattern-based repository selection (e.g., "org/*")
repos, err := a.client.ListOrgRepos(ctx, repo.Owner, repo.Pattern)
if err != nil {
return nil, fmt.Errorf("failed to list repos for %s/%s: %w", repo.Owner, repo.Pattern, err)
}
for _, r := range repos {
if err := a.collectRepoData(ctx, repo.Owner, r, dateRange, data); err != nil {
a.log("Warning: failed to collect data for %s/%s: %v", repo.Owner, r, err)
// Continue with other repos
}
}
} else {
// Single repository
if err := a.collectRepoData(ctx, repo.Owner, repo.Name, dateRange, data); err != nil {
return nil, fmt.Errorf("failed to collect data for %s/%s: %w", repo.Owner, repo.Name, err)
}
}
}
return data, nil
}
func (a *App) collectRepoData(ctx context.Context, owner, name string, dateRange *config.ParsedDateRange, data *models.RawData) error {
repoName := fmt.Sprintf("%s/%s", owner, name)
a.log(" Fetching data from %s...", repoName)
// Fetch commits - use local git if enabled (much faster)
var commits []models.Commit
var err error
if a.gitRepo != nil {
// Clone/update repository locally
token := a.config.Auth.GithubToken
cloneErr := a.gitRepo.EnsureCloned(ctx, owner, name, token)
if cloneErr != nil {
a.log(" Warning: failed to clone repository locally, falling back to API: %v", cloneErr)
// Fallback to API
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
} else {
// Use local git for commits
commits, err = a.gitRepo.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
} else {
// Use API for commits
commits, err = a.client.FetchCommits(ctx, owner, name, dateRange.Start, dateRange.End)
}
if err != nil {
return fmt.Errorf("failed to fetch commits: %w", err)
}
a.log(" Found %d commits", len(commits))
// Filter out bots
for _, c := range commits {
if !a.config.IsBot(c.Author.Login) {
data.Commits = append(data.Commits, c)
}
}
// Fetch pull requests
prs, err := a.client.FetchPullRequests(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch pull requests: %w", err)
}
a.log(" Found %d pull requests", len(prs))
for _, pr := range prs {
if !a.config.IsBot(pr.Author.Login) {
data.PullRequests = append(data.PullRequests, pr)
}
}
// Fetch reviews in parallel for all PRs (already filtered by FetchPullRequests)
if len(prs) > 0 {
a.log(" Fetching reviews for %d PRs in parallel...", len(prs))
type reviewResult struct {
reviews []models.Review
err error
}
// Use worker pool to limit concurrent requests
concurrency := a.config.Options.ConcurrentRequests
if concurrency <= 0 {
concurrency = 5
}
results := make(chan reviewResult, len(prs))
sem := make(chan struct{}, concurrency)
for _, pr := range prs {
go func(prNum int) {
sem <- struct{}{} // Acquire
defer func() { <-sem }() // Release
reviews, err := a.client.FetchReviews(ctx, owner, name, prNum)
results <- reviewResult{reviews: reviews, err: err}
}(pr.Number)
}
// Collect results
reviewCount := 0
for i := 0; i < len(prs); i++ {
result := <-results
if result.err != nil {
continue
}
for _, r := range result.reviews {
if !a.config.IsBot(r.Author.Login) {
data.Reviews = append(data.Reviews, r)
reviewCount++
}
}
}
a.log(" Found %d reviews across %d PRs", reviewCount, len(prs))
}
// Fetch issues
issues, err := a.client.FetchIssues(ctx, owner, name, dateRange.Start, dateRange.End)
if err != nil {
return fmt.Errorf("failed to fetch issues: %w", err)
}
a.log(" Found %d issues", len(issues))
for _, issue := range issues {
if !a.config.IsBot(issue.Author.Login) {
data.Issues = append(data.Issues, issue)
}
}
return nil
}
func (a *App) log(format string, args ...interface{}) {
if a.verbose {
log.Printf(format, args...)
} else {
fmt.Fprintf(os.Stderr, format+"\n", args...)
}
}
// fetchUserProfiles collects unique GitHub logins from PR/review data and fetches their profiles
// The profiles contain public emails and names that help with commit author deduplication
func (a *App) fetchUserProfiles(ctx context.Context, data *models.RawData) (map[string]aggregator.UserProfile, error) {
// Collect unique logins from PRs and reviews
loginSet := make(map[string]bool)
for _, pr := range data.PullRequests {
if pr.Author.Login != "" {
loginSet[pr.Author.Login] = true
}
}
for _, review := range data.Reviews {
if review.Author.Login != "" {
loginSet[review.Author.Login] = true
}
}
// Convert to slice
logins := make([]string, 0, len(loginSet))
for login := range loginSet {
logins = append(logins, login)
}
if len(logins) == 0 {
return make(map[string]aggregator.UserProfile), nil
}
// Fetch profiles from GitHub (uses cache)
ghProfiles, err := a.client.FetchUserProfiles(ctx, logins)
if err != nil {
return nil, err
}
// Convert to aggregator.UserProfile
profiles := make(map[string]aggregator.UserProfile)
for login, p := range ghProfiles {
profiles[login] = aggregator.UserProfile{
ID: p.ID,
Login: p.Login,
Name: p.Name,
Email: p.Email,
AvatarURL: p.AvatarURL,
}
}
return profiles, nil
}
+270
View File
@@ -0,0 +1,270 @@
package config
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// Load reads and parses a configuration file
func Load(path string) (*Config, error) {
cleanPath := filepath.Clean(path)
data, err := os.ReadFile(cleanPath) // #nosec G304 -- path is user-provided config file
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Expand environment variables
expanded := expandEnvVars(string(data))
// Start with defaults
cfg := DefaultConfig()
// Parse YAML
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Validate configuration
if err := Validate(cfg); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return cfg, nil
}
// expandEnvVars replaces ${VAR} patterns with environment variable values
func expandEnvVars(input string) string {
re := regexp.MustCompile(`\$\{([^}]+)\}`)
return re.ReplaceAllStringFunc(input, func(match string) string {
// Extract variable name
varName := strings.TrimPrefix(strings.TrimSuffix(match, "}"), "${")
return os.Getenv(varName)
})
}
// parseRelativeDate parses relative date strings like "-90d", "-2w", "-3m"
// Returns the parsed time or nil if not a relative format
func parseRelativeDate(s string) *time.Time {
if !strings.HasPrefix(s, "-") && !strings.HasPrefix(s, "+") {
return nil
}
// Parse the number and unit
s = strings.TrimSpace(s)
if len(s) < 2 {
return nil
}
unit := s[len(s)-1]
numStr := s[1 : len(s)-1] // Skip the +/- prefix and unit suffix
num := 0
for _, c := range numStr {
if c < '0' || c > '9' {
return nil
}
num = num*10 + int(c-'0')
}
if s[0] == '-' {
num = -num
}
now := time.Now()
var result time.Time
switch unit {
case 'd': // days
result = now.AddDate(0, 0, num)
case 'w': // weeks
result = now.AddDate(0, 0, num*7)
case 'm': // months
result = now.AddDate(0, num, 0)
case 'y': // years
result = now.AddDate(num, 0, 0)
default:
return nil
}
// Normalize to start of day
result = time.Date(result.Year(), result.Month(), result.Day(), 0, 0, 0, 0, result.Location())
return &result
}
// GetParsedDateRange parses and returns the date range with defaults
// Supports both absolute dates (2024-01-01) and relative dates (-90d, -2w, -3m, -1y)
func (c *Config) GetParsedDateRange() (*ParsedDateRange, error) {
result := &ParsedDateRange{}
if c.DateRange.Start != "" {
// Try relative date first
if t := parseRelativeDate(c.DateRange.Start); t != nil {
result.Start = t
} else {
// Try absolute date
t, err := time.Parse("2006-01-02", c.DateRange.Start)
if err != nil {
return nil, fmt.Errorf("invalid start date format (use YYYY-MM-DD or -Nd/-Nw/-Nm/-Ny): %w", err)
}
result.Start = &t
}
}
if c.DateRange.End != "" {
// Try relative date first
if t := parseRelativeDate(c.DateRange.End); t != nil {
// Set end to end of day
endOfDay := t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
result.End = &endOfDay
} else {
// Try absolute date
t, err := time.Parse("2006-01-02", c.DateRange.End)
if err != nil {
return nil, fmt.Errorf("invalid end date format (use YYYY-MM-DD or -Nd/-Nw/-Nm/-Ny): %w", err)
}
// Set end to end of day
t = t.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
result.End = &t
}
} else {
// Default to now
now := time.Now()
result.End = &now
}
return result, nil
}
// GetCacheTTL returns the cache TTL as a time.Duration
func (c *Config) GetCacheTTL() (time.Duration, error) {
if c.Cache.TTL == "" {
return 24 * time.Hour, nil
}
return time.ParseDuration(c.Cache.TTL)
}
// HasGithubToken returns true if token authentication is configured
func (c *Config) HasGithubToken() bool {
return c.Auth.GithubToken != ""
}
// HasGithubApp returns true if GitHub App authentication is configured
func (c *Config) HasGithubApp() bool {
return c.Auth.GithubApp != nil &&
c.Auth.GithubApp.AppID > 0 &&
c.Auth.GithubApp.InstallationID > 0 &&
(c.Auth.GithubApp.PrivateKey != "" || c.Auth.GithubApp.PrivateKeyPath != "")
}
// GetGithubAppPrivateKey returns the GitHub App private key content
func (c *Config) GetGithubAppPrivateKey() ([]byte, error) {
if c.Auth.GithubApp == nil {
return nil, fmt.Errorf("GitHub App not configured")
}
if c.Auth.GithubApp.PrivateKey != "" {
return []byte(c.Auth.GithubApp.PrivateKey), nil
}
if c.Auth.GithubApp.PrivateKeyPath != "" {
cleanPath := filepath.Clean(c.Auth.GithubApp.PrivateKeyPath)
return os.ReadFile(cleanPath) // #nosec G304 -- path is user-provided config value
}
return nil, fmt.Errorf("no private key configured")
}
// GetTeamForUser returns the team configuration for a given username
func (c *Config) GetTeamForUser(username string) *TeamConfig {
for i := range c.Teams {
for _, member := range c.Teams[i].Members {
if strings.EqualFold(member, username) {
return &c.Teams[i]
}
}
}
return nil
}
// IsBot checks if a username matches bot patterns
func (c *Config) IsBot(username string) bool {
if c.Options.IncludeBots {
return false
}
lower := strings.ToLower(username)
for _, pattern := range c.Options.BotPatterns {
pattern = strings.ToLower(pattern)
if matchPattern(lower, pattern) {
return true
}
}
return false
}
// matchPattern performs simple glob-style pattern matching
func matchPattern(s, pattern string) bool {
// Handle exact match
if !strings.Contains(pattern, "*") {
return s == pattern
}
// Handle prefix match (pattern*)
if strings.HasSuffix(pattern, "*") && !strings.HasPrefix(pattern, "*") {
return strings.HasPrefix(s, strings.TrimSuffix(pattern, "*"))
}
// Handle suffix match (*pattern)
if strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, "*") {
return strings.HasSuffix(s, strings.TrimPrefix(pattern, "*"))
}
// Handle contains match (*pattern*)
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
return strings.Contains(s, inner)
}
return false
}
// GetCustomPeriods returns parsed custom periods
func (c *Config) GetCustomPeriods() ([]ParsedCustomPeriod, error) {
var periods []ParsedCustomPeriod
for _, cp := range c.CustomPeriods {
start, err := time.Parse("2006-01-02", cp.Start)
if err != nil {
return nil, fmt.Errorf("invalid start date for period %s: %w", cp.Name, err)
}
end, err := time.Parse("2006-01-02", cp.End)
if err != nil {
return nil, fmt.Errorf("invalid end date for period %s: %w", cp.Name, err)
}
// Set end to end of day
end = end.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
periods = append(periods, ParsedCustomPeriod{
Name: cp.Name,
Start: start,
End: end,
})
}
return periods, nil
}
// ParsedCustomPeriod represents a parsed custom time period
type ParsedCustomPeriod struct {
Name string
Start time.Time
End time.Time
}
+920
View File
@@ -0,0 +1,920 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoad(t *testing.T) {
tests := []struct {
name string
configYAML string
envVars map[string]string
expectError bool
validate func(t *testing.T, cfg *Config)
}{
{
name: "valid config with token",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Equal(t, "1.0", cfg.Version)
assert.Equal(t, "ghp_test123", cfg.Auth.GithubToken)
assert.Len(t, cfg.Repositories, 1)
assert.Equal(t, "testorg", cfg.Repositories[0].Owner)
assert.Equal(t, "testrepo", cfg.Repositories[0].Name)
},
},
{
name: "config with env var substitution",
configYAML: `
version: "1.0"
auth:
github_token: "${TEST_GITHUB_TOKEN_LOAD}"
repositories:
- owner: "testorg"
name: "testrepo"
`,
envVars: map[string]string{
"TEST_GITHUB_TOKEN_LOAD": "ghp_from_env",
},
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Equal(t, "ghp_from_env", cfg.Auth.GithubToken)
},
},
{
name: "config with date range",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
date_range:
start: "2024-01-01"
end: "2024-12-31"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
dateRange, err := cfg.GetParsedDateRange()
require.NoError(t, err)
assert.NotNil(t, dateRange.Start)
assert.NotNil(t, dateRange.End)
assert.Equal(t, 2024, dateRange.Start.Year())
assert.Equal(t, time.January, dateRange.Start.Month())
assert.Equal(t, 1, dateRange.Start.Day())
},
},
{
name: "config with teams",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
teams:
- name: "Backend"
members:
- "user1"
- "user2"
color: "#3b82f6"
- name: "Frontend"
members:
- "user3"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.Len(t, cfg.Teams, 2)
assert.Equal(t, "Backend", cfg.Teams[0].Name)
assert.Contains(t, cfg.Teams[0].Members, "user1")
assert.Equal(t, "#3b82f6", cfg.Teams[0].Color)
},
},
{
name: "config with custom scoring",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
scoring:
enabled: true
points:
commit: 20
pr_merged: 100
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.Scoring.Enabled)
assert.Equal(t, 20, cfg.Scoring.Points.Commit)
assert.Equal(t, 100, cfg.Scoring.Points.PRMerged)
},
},
{
name: "config with github app",
configYAML: `
version: "1.0"
auth:
github_app:
app_id: 12345
installation_id: 67890
private_key: "test-key-content"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: false,
validate: func(t *testing.T, cfg *Config) {
assert.True(t, cfg.HasGithubApp())
assert.Equal(t, int64(12345), cfg.Auth.GithubApp.AppID)
assert.Equal(t, int64(67890), cfg.Auth.GithubApp.InstallationID)
},
},
{
name: "invalid config - no auth",
configYAML: `
version: "1.0"
repositories:
- owner: "testorg"
name: "testrepo"
`,
expectError: true,
},
{
name: "invalid config - no repositories",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
`,
expectError: true,
},
{
name: "invalid config - invalid date format",
configYAML: `
version: "1.0"
auth:
github_token: "ghp_test123"
repositories:
- owner: "testorg"
name: "testrepo"
date_range:
start: "not-a-date"
`,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Set up environment variables (sequential test due to env var usage)
for k, v := range tt.envVars {
t.Setenv(k, v)
}
// Create temp config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte(tt.configYAML), 0644)
require.NoError(t, err)
// Load config
cfg, err := Load(configPath)
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, cfg)
if tt.validate != nil {
tt.validate(t, cfg)
}
})
}
}
func TestExpandEnvVars(t *testing.T) {
// Note: Tests that use t.Setenv cannot use t.Parallel in subtests
tests := []struct {
name string
input string
envVars map[string]string
expected string
}{
{
name: "simple substitution",
input: "token: ${TEST_TOKEN_SIMPLE}",
envVars: map[string]string{"TEST_TOKEN_SIMPLE": "secret123"},
expected: "token: secret123",
},
{
name: "multiple substitutions",
input: "user: ${TEST_USER_MULTI}, pass: ${TEST_PASS_MULTI}",
envVars: map[string]string{"TEST_USER_MULTI": "admin", "TEST_PASS_MULTI": "123"},
expected: "user: admin, pass: 123",
},
{
name: "missing env var returns empty",
input: "token: ${TEST_MISSING_VAR_12345}",
envVars: map[string]string{},
expected: "token: ",
},
{
name: "no substitution needed",
input: "token: plaintext",
envVars: map[string]string{},
expected: "token: plaintext",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Sequential test due to env var usage
for k, v := range tt.envVars {
t.Setenv(k, v)
}
result := expandEnvVars(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_GetParsedDateRange(t *testing.T) {
t.Parallel()
tests := []struct {
name string
dateRange DateRangeConfig
expectError bool
validate func(t *testing.T, result *ParsedDateRange)
}{
{
name: "valid date range",
dateRange: DateRangeConfig{
Start: "2024-01-01",
End: "2024-12-31",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End)
assert.Equal(t, 2024, result.Start.Year())
assert.Equal(t, time.January, result.Start.Month())
assert.Equal(t, 2024, result.End.Year())
assert.Equal(t, time.December, result.End.Month())
},
},
{
name: "only start date",
dateRange: DateRangeConfig{
Start: "2024-06-15",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End) // Should default to now
assert.Equal(t, 2024, result.Start.Year())
assert.Equal(t, time.June, result.Start.Month())
},
},
{
name: "empty date range defaults to now",
dateRange: DateRangeConfig{},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.Nil(t, result.Start)
assert.NotNil(t, result.End)
},
},
{
name: "invalid start date",
dateRange: DateRangeConfig{
Start: "invalid",
},
expectError: true,
},
{
name: "invalid end date",
dateRange: DateRangeConfig{
Start: "2024-01-01",
End: "invalid",
},
expectError: true,
},
{
name: "relative date - 90 days ago",
dateRange: DateRangeConfig{
Start: "-90d",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
assert.NotNil(t, result.End)
// Start should be approximately 90 days ago
expected := time.Now().AddDate(0, 0, -90)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
assert.Equal(t, expected.Day(), result.Start.Day())
},
},
{
name: "relative date - 2 weeks ago",
dateRange: DateRangeConfig{
Start: "-2w",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(0, 0, -14)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
assert.Equal(t, expected.Day(), result.Start.Day())
},
},
{
name: "relative date - 3 months ago",
dateRange: DateRangeConfig{
Start: "-3m",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(0, -3, 0)
assert.Equal(t, expected.Year(), result.Start.Year())
assert.Equal(t, expected.Month(), result.Start.Month())
},
},
{
name: "relative date - 1 year ago",
dateRange: DateRangeConfig{
Start: "-1y",
},
expectError: false,
validate: func(t *testing.T, result *ParsedDateRange) {
assert.NotNil(t, result.Start)
expected := time.Now().AddDate(-1, 0, 0)
assert.Equal(t, expected.Year(), result.Start.Year())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{DateRange: tt.dateRange}
result, err := cfg.GetParsedDateRange()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.validate != nil {
tt.validate(t, result)
}
})
}
}
func TestConfig_GetCacheTTL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ttl string
expected time.Duration
expectError bool
}{
{
name: "24 hours",
ttl: "24h",
expected: 24 * time.Hour,
},
{
name: "1 hour",
ttl: "1h",
expected: 1 * time.Hour,
},
{
name: "30 minutes",
ttl: "30m",
expected: 30 * time.Minute,
},
{
name: "empty defaults to 24h",
ttl: "",
expected: 24 * time.Hour,
},
{
name: "invalid duration",
ttl: "invalid",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Cache: CacheConfig{TTL: tt.ttl}}
result, err := cfg.GetCacheTTL()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_HasGithubToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
token string
expected bool
}{
{
name: "has token",
token: "ghp_test123",
expected: true,
},
{
name: "empty token",
token: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Auth: AuthConfig{GithubToken: tt.token}}
assert.Equal(t, tt.expected, cfg.HasGithubToken())
})
}
}
func TestConfig_HasGithubApp(t *testing.T) {
t.Parallel()
tests := []struct {
name string
appCfg *GithubAppConfig
expected bool
}{
{
name: "valid github app config",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKey: "key-content",
},
expected: true,
},
{
name: "valid github app config with path",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKeyPath: "/path/to/key.pem",
},
expected: true,
},
{
name: "nil github app config",
appCfg: nil,
expected: false,
},
{
name: "missing app id",
appCfg: &GithubAppConfig{
InstallationID: 67890,
PrivateKey: "key-content",
},
expected: false,
},
{
name: "missing installation id",
appCfg: &GithubAppConfig{
AppID: 12345,
PrivateKey: "key-content",
},
expected: false,
},
{
name: "missing private key",
appCfg: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{Auth: AuthConfig{GithubApp: tt.appCfg}}
assert.Equal(t, tt.expected, cfg.HasGithubApp())
})
}
}
func TestConfig_GetTeamForUser(t *testing.T) {
t.Parallel()
cfg := &Config{
Teams: []TeamConfig{
{
Name: "Backend",
Members: []string{"alice", "bob"},
Color: "#blue",
},
{
Name: "Frontend",
Members: []string{"charlie", "dave"},
Color: "#green",
},
},
}
tests := []struct {
name string
username string
expectedTeam string
expectNil bool
}{
{
name: "user in first team",
username: "alice",
expectedTeam: "Backend",
},
{
name: "user in second team",
username: "charlie",
expectedTeam: "Frontend",
},
{
name: "case insensitive match",
username: "ALICE",
expectedTeam: "Backend",
},
{
name: "user not in any team",
username: "unknown",
expectNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
team := cfg.GetTeamForUser(tt.username)
if tt.expectNil {
assert.Nil(t, team)
} else {
require.NotNil(t, team)
assert.Equal(t, tt.expectedTeam, team.Name)
}
})
}
}
func TestConfig_IsBot(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
},
}
tests := []struct {
name string
username string
expected bool
}{
{
name: "bot suffix pattern",
username: "my-app[bot]",
expected: true,
},
{
name: "dependabot prefix pattern",
username: "dependabot-preview",
expected: true,
},
{
name: "renovate prefix pattern",
username: "renovate[bot]",
expected: true,
},
{
name: "github-actions prefix pattern",
username: "github-actions[bot]",
expected: true,
},
{
name: "regular user",
username: "alice",
expected: false,
},
{
name: "user with bot in name",
username: "robotics-engineer",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := cfg.IsBot(tt.username)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_IsBot_IncludeBots(t *testing.T) {
t.Parallel()
cfg := &Config{
Options: OptionsConfig{
IncludeBots: true,
BotPatterns: []string{"*[bot]"},
},
}
// When IncludeBots is true, nothing should be considered a bot
assert.False(t, cfg.IsBot("my-app[bot]"))
assert.False(t, cfg.IsBot("dependabot"))
}
func TestMatchPattern(t *testing.T) {
t.Parallel()
tests := []struct {
name string
s string
pattern string
expected bool
}{
{
name: "exact match",
s: "hello",
pattern: "hello",
expected: true,
},
{
name: "prefix match",
s: "hello-world",
pattern: "hello*",
expected: true,
},
{
name: "suffix match",
s: "hello-world",
pattern: "*world",
expected: true,
},
{
name: "contains match",
s: "hello-world-test",
pattern: "*world*",
expected: true,
},
{
name: "no match",
s: "hello",
pattern: "world",
expected: false,
},
{
name: "prefix no match",
s: "hello",
pattern: "world*",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := matchPattern(tt.s, tt.pattern)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConfig_GetCustomPeriods(t *testing.T) {
t.Parallel()
tests := []struct {
name string
customPeriods []CustomPeriod
expectError bool
validate func(t *testing.T, periods []ParsedCustomPeriod)
}{
{
name: "valid custom periods",
customPeriods: []CustomPeriod{
{Name: "Q1", Start: "2024-01-01", End: "2024-03-31"},
{Name: "Q2", Start: "2024-04-01", End: "2024-06-30"},
},
expectError: false,
validate: func(t *testing.T, periods []ParsedCustomPeriod) {
assert.Len(t, periods, 2)
assert.Equal(t, "Q1", periods[0].Name)
assert.Equal(t, time.January, periods[0].Start.Month())
assert.Equal(t, time.March, periods[0].End.Month())
},
},
{
name: "empty custom periods",
customPeriods: []CustomPeriod{},
expectError: false,
validate: func(t *testing.T, periods []ParsedCustomPeriod) {
assert.Empty(t, periods)
},
},
{
name: "invalid start date",
customPeriods: []CustomPeriod{
{Name: "Bad", Start: "invalid", End: "2024-03-31"},
},
expectError: true,
},
{
name: "invalid end date",
customPeriods: []CustomPeriod{
{Name: "Bad", Start: "2024-01-01", End: "invalid"},
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := &Config{CustomPeriods: tt.customPeriods}
periods, err := cfg.GetCustomPeriods()
if tt.expectError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tt.validate != nil {
tt.validate(t, periods)
}
})
}
}
func TestDefaultConfig(t *testing.T) {
t.Parallel()
cfg := DefaultConfig()
assert.Equal(t, "1.0", cfg.Version)
assert.Contains(t, cfg.Granularity, "daily")
assert.Contains(t, cfg.Granularity, "weekly")
assert.Contains(t, cfg.Granularity, "monthly")
assert.True(t, cfg.Scoring.Enabled)
assert.Equal(t, 10, cfg.Scoring.Points.Commit)
assert.Equal(t, 50, cfg.Scoring.Points.PRMerged)
assert.NotEmpty(t, cfg.Scoring.Achievements)
assert.Equal(t, "./dist", cfg.Output.Directory)
assert.True(t, cfg.Cache.Enabled)
assert.Equal(t, "./.cache", cfg.Cache.Directory)
assert.Equal(t, "24h", cfg.Cache.TTL)
assert.Equal(t, 5, cfg.Options.ConcurrentRequests)
assert.False(t, cfg.Options.IncludeBots)
}
func TestConfig_GetGithubAppPrivateKey(t *testing.T) {
t.Parallel()
t.Run("returns inline key", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
PrivateKey: "inline-key-content",
},
},
}
key, err := cfg.GetGithubAppPrivateKey()
require.NoError(t, err)
assert.Equal(t, []byte("inline-key-content"), key)
})
t.Run("returns key from file", func(t *testing.T) {
t.Parallel()
tmpDir := t.TempDir()
keyPath := filepath.Join(tmpDir, "key.pem")
err := os.WriteFile(keyPath, []byte("file-key-content"), 0600)
require.NoError(t, err)
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
PrivateKeyPath: keyPath,
},
},
}
key, err := cfg.GetGithubAppPrivateKey()
require.NoError(t, err)
assert.Equal(t, []byte("file-key-content"), key)
})
t.Run("error when no github app configured", func(t *testing.T) {
t.Parallel()
cfg := &Config{}
_, err := cfg.GetGithubAppPrivateKey()
assert.Error(t, err)
})
t.Run("error when no key configured", func(t *testing.T) {
t.Parallel()
cfg := &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
},
},
}
_, err := cfg.GetGithubAppPrivateKey()
assert.Error(t, err)
})
}
func TestLoad_FileNotFound(t *testing.T) {
t.Parallel()
_, err := Load("/nonexistent/path/config.yaml")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read config file")
}
func TestLoad_InvalidYAML(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.yaml")
err := os.WriteFile(configPath, []byte("invalid: yaml: content: ["), 0644)
require.NoError(t, err)
_, err = Load(configPath)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to parse config file")
}
+454
View File
@@ -0,0 +1,454 @@
package config
import "time"
// Config represents the main configuration structure
type Config struct {
Version string `yaml:"version"`
Auth AuthConfig `yaml:"auth"`
Repositories []RepositoryConfig `yaml:"repositories"`
DateRange DateRangeConfig `yaml:"date_range"`
Granularity []string `yaml:"granularity"`
CustomPeriods []CustomPeriod `yaml:"custom_periods,omitempty"`
Teams []TeamConfig `yaml:"teams,omitempty"`
Scoring ScoringConfig `yaml:"scoring"`
Output OutputConfig `yaml:"output"`
Cache CacheConfig `yaml:"cache"`
Options OptionsConfig `yaml:"options"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
// Token-based authentication
GithubToken string `yaml:"github_token,omitempty"`
// GitHub App authentication
GithubApp *GithubAppConfig `yaml:"github_app,omitempty"`
}
// GithubAppConfig holds GitHub App authentication details
type GithubAppConfig struct {
AppID int64 `yaml:"app_id"`
InstallationID int64 `yaml:"installation_id"`
PrivateKeyPath string `yaml:"private_key_path,omitempty"`
PrivateKey string `yaml:"private_key,omitempty"`
}
// RepositoryConfig defines a repository to analyze
type RepositoryConfig struct {
Owner string `yaml:"owner"`
Name string `yaml:"name,omitempty"`
Pattern string `yaml:"pattern,omitempty"` // For wildcard matching
}
// DateRangeConfig specifies the analysis time range
type DateRangeConfig struct {
Start string `yaml:"start,omitempty"` // ISO 8601 format
End string `yaml:"end,omitempty"` // ISO 8601 format
}
// CustomPeriod defines a custom time period for analysis
type CustomPeriod struct {
Name string `yaml:"name"`
Start string `yaml:"start"`
End string `yaml:"end"`
}
// TeamConfig defines a team and its members
type TeamConfig struct {
Name string `yaml:"name"`
Members []string `yaml:"members"`
Color string `yaml:"color,omitempty"`
}
// ScoringConfig holds gamification scoring configuration
type ScoringConfig struct {
Enabled bool `yaml:"enabled"`
Points PointsConfig `yaml:"points"`
Achievements []AchievementConfig `yaml:"achievements,omitempty"`
}
// PointsConfig defines point values for various activities
type PointsConfig struct {
Commit int `yaml:"commit"`
CommitWithTests int `yaml:"commit_with_tests"`
LinesAdded float64 `yaml:"lines_added"`
LinesDeleted float64 `yaml:"lines_deleted"`
PROpened int `yaml:"pr_opened"`
PRMerged int `yaml:"pr_merged"`
PRReviewed int `yaml:"pr_reviewed"`
ReviewComment int `yaml:"review_comment"` // PR review comments (not code comments)
IssueOpened int `yaml:"issue_opened"`
IssueClosed int `yaml:"issue_closed"`
FastReview1h int `yaml:"fast_review_1h"`
FastReview4h int `yaml:"fast_review_4h"`
FastReview24h int `yaml:"fast_review_24h"`
}
// AchievementConfig defines an achievement badge
type AchievementConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Icon string `yaml:"icon"`
Condition AchievementCondition `yaml:"condition"`
}
// AchievementCondition defines when an achievement is earned
type AchievementCondition struct {
Type string `yaml:"type"` // commit_count, pr_count, review_count, avg_review_time, etc.
Threshold float64 `yaml:"threshold"`
}
// TierFromThreshold returns the tier level (1-11) based on threshold value
// Tiers: 1=1, 2=10, 3=25, 4=50, 5=100, 6=250, 7=500, 8=1000, 9=5000, 10=10000, 11=25000+
func TierFromThreshold(threshold float64) int {
tiers := []float64{1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000}
for i := len(tiers) - 1; i >= 0; i-- {
if threshold >= tiers[i] {
return i + 1
}
}
return 1
}
// OutputConfig specifies output generation settings
type OutputConfig struct {
Directory string `yaml:"directory"`
Format []string `yaml:"format"` // html, json
Deploy DeployConfig `yaml:"deploy"`
}
// DeployConfig specifies deployment options
type DeployConfig struct {
GHPages bool `yaml:"gh_pages"`
Artifact bool `yaml:"artifact"`
}
// CacheConfig holds caching configuration
type CacheConfig struct {
Enabled bool `yaml:"enabled"`
Directory string `yaml:"directory"`
TTL string `yaml:"ttl"` // Duration string like "24h"
}
// OptionsConfig holds advanced options
type OptionsConfig struct {
ConcurrentRequests int `yaml:"concurrent_requests"`
IncludeBots bool `yaml:"include_bots"`
BotPatterns []string `yaml:"bot_patterns"`
CloneDirectory string `yaml:"clone_directory"` // Directory for local git clones
UseLocalGit bool `yaml:"use_local_git"` // Use local git for commits (faster)
UserAliases []UserAlias `yaml:"user_aliases,omitempty"` // Manual email/name to login mappings
}
// UserAlias maps git emails or names to a GitHub login
type UserAlias struct {
GithubLogin string `yaml:"github_login"` // The canonical GitHub username
Emails []string `yaml:"emails,omitempty"` // Git commit emails to map
Names []string `yaml:"names,omitempty"` // Git commit author names to map
}
// ParsedDateRange holds parsed date range values
type ParsedDateRange struct {
Start *time.Time
End *time.Time
}
// DefaultConfig returns a configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
Version: "1.0",
Granularity: []string{"daily", "weekly", "monthly"},
Scoring: ScoringConfig{
Enabled: true,
Points: PointsConfig{
Commit: 10,
CommitWithTests: 15,
LinesAdded: 0.1,
LinesDeleted: 0.05,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
IssueOpened: 15,
IssueClosed: 20,
FastReview1h: 50,
FastReview4h: 25,
FastReview24h: 10,
},
Achievements: defaultAchievements(),
},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html", "json"},
Deploy: DeployConfig{
GHPages: true,
Artifact: true,
},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
IncludeBots: false,
BotPatterns: []string{
"*[bot]",
"dependabot*",
"renovate*",
"github-actions*",
},
CloneDirectory: "./.repos",
UseLocalGit: true, // Default to faster local git analysis
},
}
}
// defaultAchievements returns the default achievement badges
func defaultAchievements() []AchievementConfig {
return []AchievementConfig{
{
ID: "first-commit",
Name: "First Steps",
Description: "Made your first commit",
Icon: "fa-baby",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1},
},
{
ID: "commit-10",
Name: "Getting Started",
Description: "Made 10 commits",
Icon: "fa-seedling",
Condition: AchievementCondition{Type: "commit_count", Threshold: 10},
},
{
ID: "commit-100",
Name: "Committed",
Description: "Made 100 commits",
Icon: "fa-fire",
Condition: AchievementCondition{Type: "commit_count", Threshold: 100},
},
{
ID: "commit-500",
Name: "Code Machine",
Description: "Made 500 commits",
Icon: "fa-robot",
Condition: AchievementCondition{Type: "commit_count", Threshold: 500},
},
{
ID: "commit-1000",
Name: "Code Warrior",
Description: "Made 1000 commits",
Icon: "fa-crown",
Condition: AchievementCondition{Type: "commit_count", Threshold: 1000},
},
{
ID: "pr-opener",
Name: "PR Pioneer",
Description: "Opened your first pull request",
Icon: "fa-code-pull-request",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 1},
},
{
ID: "pr-10",
Name: "Pull Request Pro",
Description: "Opened 10 pull requests",
Icon: "fa-code-branch",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 10},
},
{
ID: "pr-50",
Name: "Merge Master",
Description: "Opened 50 pull requests",
Icon: "fa-code-merge",
Condition: AchievementCondition{Type: "pr_opened_count", Threshold: 50},
},
{
ID: "reviewer",
Name: "Code Reviewer",
Description: "Reviewed your first pull request",
Icon: "fa-magnifying-glass-chart",
Condition: AchievementCondition{Type: "review_count", Threshold: 1},
},
{
ID: "reviewer-25",
Name: "Review Regular",
Description: "Reviewed 25 pull requests",
Icon: "fa-eye",
Condition: AchievementCondition{Type: "review_count", Threshold: 25},
},
{
ID: "reviewer-100",
Name: "Review Guru",
Description: "Reviewed 100 pull requests",
Icon: "fa-user-graduate",
Condition: AchievementCondition{Type: "review_count", Threshold: 100},
},
{
ID: "speed-demon",
Name: "Speed Demon",
Description: "Average review response under 1 hour",
Icon: "fa-bolt",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 1},
},
{
ID: "quick-responder",
Name: "Quick Responder",
Description: "Average review response under 4 hours",
Icon: "fa-clock",
Condition: AchievementCondition{Type: "avg_review_time_hours", Threshold: 4},
},
{
ID: "commentator",
Name: "Commentator",
Description: "Left 50 PR review comments",
Icon: "fa-comments",
Condition: AchievementCondition{Type: "comment_count", Threshold: 50},
},
{
ID: "lines-1000",
Name: "Thousand Lines",
Description: "Added 1000 lines of code",
Icon: "fa-layer-group",
Condition: AchievementCondition{Type: "lines_added", Threshold: 1000},
},
{
ID: "lines-10000",
Name: "Ten Thousand",
Description: "Added 10000 lines of code",
Icon: "fa-mountain",
Condition: AchievementCondition{Type: "lines_added", Threshold: 10000},
},
{
ID: "cleaner",
Name: "Code Cleaner",
Description: "Deleted 1000 lines of code",
Icon: "fa-broom",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 1000},
},
{
ID: "refactorer",
Name: "Refactoring Champion",
Description: "Deleted 10000 lines of code",
Icon: "fa-recycle",
Condition: AchievementCondition{Type: "lines_deleted", Threshold: 10000},
},
{
ID: "multi-repo",
Name: "Multi-Repo Master",
Description: "Contributed to 5 repositories",
Icon: "fa-folder-tree",
Condition: AchievementCondition{Type: "repo_count", Threshold: 5},
},
{
ID: "team-player",
Name: "Team Player",
Description: "Reviewed PRs from 10 different contributors",
Icon: "fa-people-group",
Condition: AchievementCondition{Type: "unique_reviewees", Threshold: 10},
},
// PR Quality achievements
{
ID: "big-pr",
Name: "Heavy Lifter",
Description: "Merged a PR with 1000+ lines changed",
Icon: "fa-weight-hanging",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 1000},
},
{
ID: "mega-pr",
Name: "Mega Merge",
Description: "Merged a PR with 5000+ lines changed",
Icon: "fa-dumbbell",
Condition: AchievementCondition{Type: "largest_pr_size", Threshold: 5000},
},
{
ID: "small-pr-10",
Name: "Small PR Advocate",
Description: "Merged 10 PRs under 100 lines",
Icon: "fa-compress",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 10},
},
{
ID: "small-pr-50",
Name: "Atomic Commits Hero",
Description: "Merged 50 PRs under 100 lines",
Icon: "fa-atom",
Condition: AchievementCondition{Type: "small_pr_count", Threshold: 50},
},
{
ID: "perfect-pr-5",
Name: "Clean Code",
Description: "5 PRs merged without changes requested",
Icon: "fa-check-double",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 5},
},
{
ID: "perfect-pr-25",
Name: "Flawless",
Description: "25 PRs merged without changes requested",
Icon: "fa-gem",
Condition: AchievementCondition{Type: "perfect_prs", Threshold: 25},
},
// Activity pattern achievements
{
ID: "streak-7",
Name: "Week Warrior",
Description: "7 day contribution streak",
Icon: "fa-calendar-week",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 7},
},
{
ID: "streak-30",
Name: "Month Master",
Description: "30 day contribution streak",
Icon: "fa-calendar-check",
Condition: AchievementCondition{Type: "longest_streak", Threshold: 30},
},
{
ID: "early-bird",
Name: "Early Bird",
Description: "50 commits before 9am",
Icon: "fa-sun",
Condition: AchievementCondition{Type: "early_bird_count", Threshold: 50},
},
{
ID: "night-owl",
Name: "Night Owl",
Description: "50 commits after 9pm",
Icon: "fa-moon",
Condition: AchievementCondition{Type: "night_owl_count", Threshold: 50},
},
{
ID: "nosferatu",
Name: "Nosferatu",
Description: "25 commits between midnight and 4am",
Icon: "fa-skull",
Condition: AchievementCondition{Type: "midnight_count", Threshold: 25},
},
{
ID: "weekend-warrior",
Name: "Weekend Warrior",
Description: "25 weekend commits",
Icon: "fa-couch",
Condition: AchievementCondition{Type: "weekend_warrior", Threshold: 25},
},
{
ID: "active-30",
Name: "Consistent Contributor",
Description: "Active on 30 different days",
Icon: "fa-chart-line",
Condition: AchievementCondition{Type: "active_days", Threshold: 30},
},
{
ID: "active-100",
Name: "Dedicated Developer",
Description: "Active on 100 different days",
Icon: "fa-fire-flame-curved",
Condition: AchievementCondition{Type: "active_days", Threshold: 100},
},
}
}
+227
View File
@@ -0,0 +1,227 @@
package config
import (
"fmt"
"strings"
)
// ValidationError represents a configuration validation error
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// ValidationErrors is a collection of validation errors
type ValidationErrors []ValidationError
func (e ValidationErrors) Error() string {
if len(e) == 0 {
return ""
}
var msgs []string
for _, err := range e {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
// Validate checks the configuration for errors
func Validate(cfg *Config) error {
var errs ValidationErrors
// Validate authentication
if !cfg.HasGithubToken() && !cfg.HasGithubApp() {
errs = append(errs, ValidationError{
Field: "auth",
Message: "either github_token or github_app must be configured",
})
}
// Validate repositories
if len(cfg.Repositories) == 0 {
errs = append(errs, ValidationError{
Field: "repositories",
Message: "at least one repository must be specified",
})
}
for i, repo := range cfg.Repositories {
if repo.Owner == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("repositories[%d].owner", i),
Message: "owner is required",
})
}
if repo.Name == "" && repo.Pattern == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("repositories[%d]", i),
Message: "either name or pattern must be specified",
})
}
}
// Validate date range
if cfg.DateRange.Start != "" {
if _, err := cfg.GetParsedDateRange(); err != nil {
errs = append(errs, ValidationError{
Field: "date_range",
Message: err.Error(),
})
}
}
// Validate granularity
validGranularities := map[string]bool{
"daily": true,
"weekly": true,
"monthly": true,
}
for _, g := range cfg.Granularity {
if !validGranularities[g] {
errs = append(errs, ValidationError{
Field: "granularity",
Message: fmt.Sprintf("invalid granularity: %s (must be daily, weekly, or monthly)", g),
})
}
}
// Validate teams
for i, team := range cfg.Teams {
if team.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("teams[%d].name", i),
Message: "team name is required",
})
}
if len(team.Members) == 0 {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("teams[%d].members", i),
Message: "team must have at least one member",
})
}
}
// Validate scoring
if cfg.Scoring.Enabled {
if cfg.Scoring.Points.Commit < 0 {
errs = append(errs, ValidationError{
Field: "scoring.points.commit",
Message: "point values cannot be negative",
})
}
// Additional point validations can be added here
}
// Validate achievements
achievementIDs := make(map[string]bool)
for i, achievement := range cfg.Scoring.Achievements {
if achievement.ID == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: "achievement ID is required",
})
}
if achievementIDs[achievement.ID] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].id", i),
Message: fmt.Sprintf("duplicate achievement ID: %s", achievement.ID),
})
}
achievementIDs[achievement.ID] = true
if achievement.Name == "" {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].name", i),
Message: "achievement name is required",
})
}
validConditionTypes := map[string]bool{
"commit_count": true,
"pr_opened_count": true,
"pr_merged_count": true,
"review_count": true,
"comment_count": true,
"lines_added": true,
"lines_deleted": true,
"avg_review_time_hours": true,
"repo_count": true,
"unique_reviewees": true,
// PR quality metrics
"largest_pr_size": true,
"small_pr_count": true,
"perfect_prs": true,
// Activity pattern metrics
"active_days": true,
"longest_streak": true,
"early_bird_count": true,
"night_owl_count": true,
"midnight_count": true,
"weekend_warrior": true,
}
if !validConditionTypes[achievement.Condition.Type] {
errs = append(errs, ValidationError{
Field: fmt.Sprintf("scoring.achievements[%d].condition.type", i),
Message: fmt.Sprintf("invalid condition type: %s", achievement.Condition.Type),
})
}
}
// Validate output
if cfg.Output.Directory == "" {
errs = append(errs, ValidationError{
Field: "output.directory",
Message: "output directory is required",
})
}
validFormats := map[string]bool{"html": true, "json": true}
for _, format := range cfg.Output.Format {
if !validFormats[format] {
errs = append(errs, ValidationError{
Field: "output.format",
Message: fmt.Sprintf("invalid format: %s (must be html or json)", format),
})
}
}
// Validate cache
if cfg.Cache.Enabled {
if cfg.Cache.Directory == "" {
errs = append(errs, ValidationError{
Field: "cache.directory",
Message: "cache directory is required when caching is enabled",
})
}
if _, err := cfg.GetCacheTTL(); err != nil {
errs = append(errs, ValidationError{
Field: "cache.ttl",
Message: fmt.Sprintf("invalid TTL duration: %v", err),
})
}
}
// Validate options
if cfg.Options.ConcurrentRequests < 1 {
errs = append(errs, ValidationError{
Field: "options.concurrent_requests",
Message: "must be at least 1",
})
}
if cfg.Options.ConcurrentRequests > 20 {
errs = append(errs, ValidationError{
Field: "options.concurrent_requests",
Message: "should not exceed 20 to avoid rate limiting",
})
}
if len(errs) > 0 {
return errs
}
return nil
}
+493
View File
@@ -0,0 +1,493 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *Config
expectError bool
errorField string
}{
{
name: "valid config with token",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily", "weekly"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html", "json"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "valid config with github app",
config: &Config{
Auth: AuthConfig{
GithubApp: &GithubAppConfig{
AppID: 12345,
InstallationID: 67890,
PrivateKey: "key-content",
},
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "missing authentication",
config: &Config{
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "auth",
},
{
name: "no repositories",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories",
},
{
name: "repository missing owner",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories[0].owner",
},
{
name: "repository missing name and pattern",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "repositories[0]",
},
{
name: "repository with pattern instead of name is valid",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Pattern: "*"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: false,
},
{
name: "invalid granularity",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"invalid"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "granularity",
},
{
name: "team without name",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Teams: []TeamConfig{
{Members: []string{"user1"}},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "teams[0].name",
},
{
name: "team without members",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Teams: []TeamConfig{
{Name: "Backend", Members: []string{}},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "teams[0].members",
},
{
name: "duplicate achievement id",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test-achievement", Name: "Test 1", Condition: AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "test-achievement", Name: "Test 2", Condition: AchievementCondition{Type: "commit_count", Threshold: 20}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[1].id",
},
{
name: "invalid achievement condition type",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Scoring: ScoringConfig{
Enabled: true,
Achievements: []AchievementConfig{
{ID: "test", Name: "Test", Condition: AchievementCondition{Type: "invalid_type", Threshold: 10}},
},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "scoring.achievements[0].condition.type",
},
{
name: "missing output directory",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "output.directory",
},
{
name: "invalid output format",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"invalid"},
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "output.format",
},
{
name: "cache enabled but no directory",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "",
TTL: "24h",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "cache.directory",
},
{
name: "invalid cache TTL",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Cache: CacheConfig{
Enabled: true,
Directory: "./.cache",
TTL: "invalid",
},
Options: OptionsConfig{
ConcurrentRequests: 5,
},
},
expectError: true,
errorField: "cache.ttl",
},
{
name: "concurrent requests too low",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 0,
},
},
expectError: true,
errorField: "options.concurrent_requests",
},
{
name: "concurrent requests too high",
config: &Config{
Auth: AuthConfig{
GithubToken: "ghp_test123",
},
Repositories: []RepositoryConfig{
{Owner: "testorg", Name: "testrepo"},
},
Granularity: []string{"daily"},
Output: OutputConfig{
Directory: "./dist",
Format: []string{"html"},
},
Options: OptionsConfig{
ConcurrentRequests: 100,
},
},
expectError: true,
errorField: "options.concurrent_requests",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := Validate(tt.config)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errorField)
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidationError_Error(t *testing.T) {
t.Parallel()
err := ValidationError{
Field: "test.field",
Message: "test error message",
}
assert.Equal(t, "test.field: test error message", err.Error())
}
func TestValidationErrors_Error(t *testing.T) {
t.Parallel()
tests := []struct {
name string
errs ValidationErrors
expected string
}{
{
name: "empty errors",
errs: ValidationErrors{},
expected: "",
},
{
name: "single error",
errs: ValidationErrors{
{Field: "field1", Message: "error1"},
},
expected: "field1: error1",
},
{
name: "multiple errors",
errs: ValidationErrors{
{Field: "field1", Message: "error1"},
{Field: "field2", Message: "error2"},
},
expected: "field1: error1; field2: error2",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.expected, tt.errs.Error())
})
}
}
+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"`
}
+312
View File
@@ -0,0 +1,312 @@
package scoring
import (
"sort"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
// Calculator handles score and achievement calculations
type Calculator struct {
config *config.Config
}
// NewCalculator creates a new scoring calculator
func NewCalculator(cfg *config.Config) *Calculator {
return &Calculator{config: cfg}
}
// Calculate computes scores and achievements for all metrics
func (c *Calculator) Calculate(metrics *models.GlobalMetrics) *models.GlobalMetrics {
if !c.config.Scoring.Enabled {
return metrics
}
// Collect all contributor metrics across repositories
contributorMap := make(map[string]*models.ContributorMetrics)
for _, repo := range metrics.Repositories {
for i := range repo.Contributors {
login := repo.Contributors[i].Login
if _, ok := contributorMap[login]; !ok {
// Copy the contributor metrics
cm := repo.Contributors[i]
contributorMap[login] = &cm
} else {
// Aggregate metrics from multiple repos
existing := contributorMap[login]
cm := repo.Contributors[i]
existing.CommitCount += cm.CommitCount
existing.LinesAdded += cm.LinesAdded
existing.LinesDeleted += cm.LinesDeleted
existing.PRsOpened += cm.PRsOpened
existing.PRsMerged += cm.PRsMerged
existing.ReviewsGiven += cm.ReviewsGiven
existing.ReviewComments += cm.ReviewComments
// Combine unique repositories
for _, r := range cm.RepositoriesContributed {
if !contains(existing.RepositoriesContributed, r) {
existing.RepositoriesContributed = append(existing.RepositoriesContributed, r)
}
}
}
}
}
// Calculate scores for each contributor
for _, cm := range contributorMap {
cm.Score = c.calculateScore(cm)
// Check achievements
cm.Achievements = c.checkAchievements(cm)
}
// Convert to slice and sort by score
var contributors []models.ContributorMetrics
for _, cm := range contributorMap {
contributors = append(contributors, *cm)
}
sort.Slice(contributors, func(i, j int) bool {
return contributors[i].Score.Total > contributors[j].Score.Total
})
// Assign ranks
for i := range contributors {
contributors[i].Score.Rank = i + 1
contributors[i].Score.PercentileRank = float64(len(contributors)-i) / float64(len(contributors)) * 100
}
// Build leaderboard
leaderboard := make([]models.LeaderboardEntry, len(contributors))
topAchievers := make(map[string]string)
for i, cm := range contributors {
// Find team for user
team := ""
if teamCfg := c.config.GetTeamForUser(cm.Login); teamCfg != nil {
team = teamCfg.Name
}
// Determine top category
topCategory := c.determineTopCategory(&cm)
leaderboard[i] = models.LeaderboardEntry{
Rank: i + 1,
Login: cm.Login,
Name: cm.Name,
AvatarURL: cm.AvatarURL,
Score: cm.Score.Total,
Team: team,
TopCategory: topCategory,
Achievements: cm.Achievements,
}
// Track top achievers
if i == 0 {
topAchievers["overall"] = cm.Login
}
}
// Find top achievers in each category
c.findTopAchievers(contributors, topAchievers)
// Update the metrics
metrics.Leaderboard = leaderboard
metrics.TopAchievers = topAchievers
// Calculate per-repository scores (based on repo-specific metrics, not global)
for i := range metrics.Repositories {
for j := range metrics.Repositories[i].Contributors {
repoContrib := &metrics.Repositories[i].Contributors[j]
repoContrib.Score = c.calculateScore(repoContrib)
// Achievements are based on repo-specific activity
repoContrib.Achievements = c.checkAchievements(repoContrib)
}
// Re-sort by score after calculation
sort.Slice(metrics.Repositories[i].Contributors, func(a, b int) bool {
return metrics.Repositories[i].Contributors[a].Score.Total > metrics.Repositories[i].Contributors[b].Score.Total
})
}
// Update team scores
for i := range metrics.Teams {
var totalScore int
for j := range metrics.Teams[i].MemberMetrics {
login := metrics.Teams[i].MemberMetrics[j].Login
if cm, ok := contributorMap[login]; ok {
metrics.Teams[i].MemberMetrics[j].Score = cm.Score
metrics.Teams[i].MemberMetrics[j].Achievements = cm.Achievements
totalScore += cm.Score.Total
}
}
metrics.Teams[i].TotalScore = totalScore
if len(metrics.Teams[i].MemberMetrics) > 0 {
metrics.Teams[i].AvgScore = float64(totalScore) / float64(len(metrics.Teams[i].MemberMetrics))
}
}
return metrics
}
// calculateScore computes the score for a contributor based on their metrics
func (c *Calculator) calculateScore(cm *models.ContributorMetrics) models.Score {
points := c.config.Scoring.Points
breakdown := models.ScoreBreakdown{}
// Commit points
breakdown.Commits = cm.CommitCount * points.Commit
// Line change points
breakdown.LineChanges = int(float64(cm.LinesAdded)*points.LinesAdded +
float64(cm.LinesDeleted)*points.LinesDeleted)
// PR points
breakdown.PRs = cm.PRsOpened*points.PROpened + cm.PRsMerged*points.PRMerged
// Review points (PR reviews and PR review comments)
breakdown.Reviews = cm.ReviewsGiven*points.PRReviewed +
cm.ReviewComments*points.ReviewComment
// Response time bonus
if cm.ReviewsGiven > 0 && cm.AvgReviewTime > 0 {
if cm.AvgReviewTime <= 1 {
breakdown.ResponseBonus = points.FastReview1h
} else if cm.AvgReviewTime <= 4 {
breakdown.ResponseBonus = points.FastReview4h
} else if cm.AvgReviewTime <= 24 {
breakdown.ResponseBonus = points.FastReview24h
}
}
// Calculate total
total := breakdown.Commits + breakdown.LineChanges + breakdown.PRs +
breakdown.Reviews + breakdown.ResponseBonus + breakdown.Comments
return models.Score{
Total: total,
Breakdown: breakdown,
}
}
func (c *Calculator) checkAchievements(cm *models.ContributorMetrics) []string {
// Collect ALL earned achievements (including all tiers)
var achievements []string
for _, ach := range c.config.Scoring.Achievements {
earned := false
switch ach.Condition.Type {
case "commit_count":
earned = float64(cm.CommitCount) >= ach.Condition.Threshold
case "pr_opened_count":
earned = float64(cm.PRsOpened) >= ach.Condition.Threshold
case "pr_merged_count":
earned = float64(cm.PRsMerged) >= ach.Condition.Threshold
case "review_count":
earned = float64(cm.ReviewsGiven) >= ach.Condition.Threshold
case "comment_count":
earned = float64(cm.ReviewComments) >= ach.Condition.Threshold
case "lines_added":
earned = float64(cm.LinesAdded) >= ach.Condition.Threshold
case "lines_deleted":
earned = float64(cm.LinesDeleted) >= ach.Condition.Threshold
case "avg_review_time_hours":
// For avg review time, lower is better, so lower threshold = harder achievement
if cm.AvgReviewTime > 0 && cm.AvgReviewTime <= ach.Condition.Threshold {
earned = true
}
case "repo_count":
earned = float64(len(cm.RepositoriesContributed)) >= ach.Condition.Threshold
case "unique_reviewees":
earned = float64(cm.UniqueReviewees) >= ach.Condition.Threshold
// New PR quality metrics
case "largest_pr_size":
earned = float64(cm.LargestPRSize) >= ach.Condition.Threshold
case "small_pr_count":
earned = float64(cm.SmallPRCount) >= ach.Condition.Threshold
case "perfect_prs":
earned = float64(cm.PerfectPRs) >= ach.Condition.Threshold
// Activity pattern metrics
case "active_days":
earned = float64(cm.ActiveDays) >= ach.Condition.Threshold
case "longest_streak":
earned = float64(cm.LongestStreak) >= ach.Condition.Threshold
case "early_bird_count":
earned = float64(cm.EarlyBirdCount) >= ach.Condition.Threshold
case "night_owl_count":
earned = float64(cm.NightOwlCount) >= ach.Condition.Threshold
case "midnight_count":
earned = float64(cm.MidnightCount) >= ach.Condition.Threshold
case "weekend_warrior":
earned = float64(cm.WeekendWarrior) >= ach.Condition.Threshold
}
if earned {
achievements = append(achievements, ach.ID)
}
}
return achievements
}
func (c *Calculator) determineTopCategory(cm *models.ContributorMetrics) string {
// Determine what the contributor is best at
categories := map[string]int{
"Commits": cm.CommitCount,
"PRs": cm.PRsOpened,
"Reviews": cm.ReviewsGiven,
"Comments": cm.ReviewComments,
}
topCategory := ""
topValue := 0
for category, value := range categories {
if value > topValue {
topValue = value
topCategory = category
}
}
return topCategory
}
func (c *Calculator) findTopAchievers(contributors []models.ContributorMetrics, topAchievers map[string]string) {
var topCommitter, topReviewer, topPRAuthor string
var maxCommits, maxReviews, maxPRs int
for _, cm := range contributors {
if cm.CommitCount > maxCommits {
maxCommits = cm.CommitCount
topCommitter = cm.Login
}
if cm.ReviewsGiven > maxReviews {
maxReviews = cm.ReviewsGiven
topReviewer = cm.Login
}
if cm.PRsOpened > maxPRs {
maxPRs = cm.PRsOpened
topPRAuthor = cm.Login
}
}
if topCommitter != "" {
topAchievers["commits"] = topCommitter
}
if topReviewer != "" {
topAchievers["reviews"] = topReviewer
}
if topPRAuthor != "" {
topAchievers["pull_requests"] = topPRAuthor
}
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
+714
View File
@@ -0,0 +1,714 @@
package scoring
import (
"testing"
"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 TestNewCalculator(t *testing.T) {
t.Parallel()
cfg := &config.Config{}
calc := NewCalculator(cfg)
assert.NotNil(t, calc)
assert.Equal(t, cfg, calc.config)
}
func TestCalculator_ScoringDisabled(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = false
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 100},
},
},
},
}
result := calc.Calculate(metrics)
// Should return unchanged metrics when scoring is disabled
assert.Equal(t, 0, result.Repositories[0].Contributors[0].Score.Total)
assert.Empty(t, result.Leaderboard)
}
func TestCalculator_BasicScoring(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
PROpened: 25,
PRMerged: 50,
PRReviewed: 30,
ReviewComment: 5,
LinesAdded: 0.1,
LinesDeleted: 0.05,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
Name: "User One",
CommitCount: 10,
LinesAdded: 1000,
LinesDeleted: 500,
PRsOpened: 5,
PRsMerged: 3,
ReviewsGiven: 8,
ReviewComments: 20,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
entry := result.Leaderboard[0]
assert.Equal(t, "user1", entry.Login)
assert.Equal(t, 1, entry.Rank)
// Verify score breakdown:
// Commits: 10 * 10 = 100
// Lines: 1000 * 0.1 + 500 * 0.05 = 100 + 25 = 125
// PRs: 5 * 25 + 3 * 50 = 125 + 150 = 275
// Reviews: 8 * 30 + 20 * 5 = 240 + 100 = 340
// Total: 100 + 125 + 275 + 340 = 840
assert.Equal(t, 840, entry.Score)
}
func TestCalculator_FastReviewBonus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
avgReviewTime float64
expectedBonus int
expectedPoints config.PointsConfig
}{
{
name: "1 hour review gets 1h bonus",
avgReviewTime: 0.5,
expectedBonus: 50,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "3 hour review gets 4h bonus",
avgReviewTime: 3.0,
expectedBonus: 30,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "12 hour review gets 24h bonus",
avgReviewTime: 12.0,
expectedBonus: 10,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
{
name: "48 hour review gets no bonus",
avgReviewTime: 48.0,
expectedBonus: 0,
expectedPoints: config.PointsConfig{
FastReview1h: 50,
FastReview4h: 30,
FastReview24h: 10,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = tt.expectedPoints
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
ReviewsGiven: 5,
AvgReviewTime: tt.avgReviewTime,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 1)
// Get the contributor from the repository to check breakdown
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, tt.expectedBonus, contributor.Score.Breakdown.ResponseBonus)
})
}
}
func TestCalculator_MultipleContributorsRanking(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 100,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "user2",
CommitCount: 50,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "user3",
CommitCount: 200,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 3)
// Should be sorted by score (highest first)
assert.Equal(t, "user3", result.Leaderboard[0].Login)
assert.Equal(t, 1, result.Leaderboard[0].Rank)
assert.Equal(t, 2000, result.Leaderboard[0].Score)
assert.Equal(t, "user1", result.Leaderboard[1].Login)
assert.Equal(t, 2, result.Leaderboard[1].Rank)
assert.Equal(t, 1000, result.Leaderboard[1].Score)
assert.Equal(t, "user2", result.Leaderboard[2].Login)
assert.Equal(t, 3, result.Leaderboard[2].Rank)
assert.Equal(t, 500, result.Leaderboard[2].Score)
}
func TestCalculator_PercentileRank(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 100, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 80, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user3", CommitCount: 60, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user4", CommitCount: 40, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Leaderboard, 4)
// Leaderboard should be sorted by score (highest first)
// user1: 100 commits * 10 = 1000, rank 1
// user2: 80 commits * 10 = 800, rank 2
// user3: 60 commits * 10 = 600, rank 3
// user4: 40 commits * 10 = 400, rank 4
assert.Equal(t, "user1", result.Leaderboard[0].Login)
assert.Equal(t, 1, result.Leaderboard[0].Rank)
assert.Equal(t, 1000, result.Leaderboard[0].Score)
assert.Equal(t, "user2", result.Leaderboard[1].Login)
assert.Equal(t, 2, result.Leaderboard[1].Rank)
assert.Equal(t, 800, result.Leaderboard[1].Score)
assert.Equal(t, "user3", result.Leaderboard[2].Login)
assert.Equal(t, 3, result.Leaderboard[2].Rank)
assert.Equal(t, 600, result.Leaderboard[2].Score)
assert.Equal(t, "user4", result.Leaderboard[3].Login)
assert.Equal(t, 4, result.Leaderboard[3].Rank)
assert.Equal(t, 400, result.Leaderboard[3].Score)
}
func TestCalculator_Achievements(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Achievements = []config.AchievementConfig{
{
ID: "commit-10",
Name: "10 Commits",
Condition: config.AchievementCondition{
Type: "commit_count",
Threshold: 10,
},
},
{
ID: "pr-master",
Name: "PR Master",
Condition: config.AchievementCondition{
Type: "pr_opened_count",
Threshold: 5,
},
},
{
ID: "reviewer",
Name: "Reviewer",
Condition: config.AchievementCondition{
Type: "review_count",
Threshold: 10,
},
},
{
ID: "speed-demon",
Name: "Speed Demon",
Condition: config.AchievementCondition{
Type: "avg_review_time_hours",
Threshold: 1.0,
},
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 15,
PRsOpened: 6,
ReviewsGiven: 5,
AvgReviewTime: 0.5,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have commit-10, pr-master, and speed-demon
// Should NOT have reviewer (only 5 reviews, need 10)
assert.Contains(t, contributor.Achievements, "commit-10")
assert.Contains(t, contributor.Achievements, "pr-master")
assert.Contains(t, contributor.Achievements, "speed-demon")
assert.NotContains(t, contributor.Achievements, "reviewer")
}
func TestCalculator_AllAchievementTypes(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Achievements = []config.AchievementConfig{
{ID: "commits", Condition: config.AchievementCondition{Type: "commit_count", Threshold: 10}},
{ID: "prs-opened", Condition: config.AchievementCondition{Type: "pr_opened_count", Threshold: 5}},
{ID: "prs-merged", Condition: config.AchievementCondition{Type: "pr_merged_count", Threshold: 3}},
{ID: "reviews", Condition: config.AchievementCondition{Type: "review_count", Threshold: 8}},
{ID: "comments", Condition: config.AchievementCondition{Type: "comment_count", Threshold: 20}},
{ID: "lines-added", Condition: config.AchievementCondition{Type: "lines_added", Threshold: 1000}},
{ID: "lines-deleted", Condition: config.AchievementCondition{Type: "lines_deleted", Threshold: 500}},
{ID: "fast-review", Condition: config.AchievementCondition{Type: "avg_review_time_hours", Threshold: 2}},
{ID: "multi-repo", Condition: config.AchievementCondition{Type: "repo_count", Threshold: 2}},
{ID: "team-player", Condition: config.AchievementCondition{Type: "unique_reviewees", Threshold: 5}},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
CommitCount: 15,
PRsOpened: 6,
PRsMerged: 4,
ReviewsGiven: 10,
ReviewComments: 25,
LinesAdded: 1500,
LinesDeleted: 600,
AvgReviewTime: 1.5,
UniqueReviewees: 7,
RepositoriesContributed: []string{"owner/repo1", "owner/repo2"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should have all achievements
assert.Len(t, contributor.Achievements, 10)
assert.Contains(t, contributor.Achievements, "commits")
assert.Contains(t, contributor.Achievements, "prs-opened")
assert.Contains(t, contributor.Achievements, "prs-merged")
assert.Contains(t, contributor.Achievements, "reviews")
assert.Contains(t, contributor.Achievements, "comments")
assert.Contains(t, contributor.Achievements, "lines-added")
assert.Contains(t, contributor.Achievements, "lines-deleted")
assert.Contains(t, contributor.Achievements, "fast-review")
assert.Contains(t, contributor.Achievements, "multi-repo")
assert.Contains(t, contributor.Achievements, "team-player")
}
func TestCalculator_TopAchievers(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
Commit: 10,
PROpened: 25,
PRReviewed: 30,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "committer",
CommitCount: 100,
PRsOpened: 5,
ReviewsGiven: 2,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "pr-author",
CommitCount: 10,
PRsOpened: 50,
ReviewsGiven: 3,
RepositoriesContributed: []string{"owner/repo"},
},
{
Login: "reviewer",
CommitCount: 5,
PRsOpened: 2,
ReviewsGiven: 100,
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
assert.Equal(t, "committer", result.TopAchievers["commits"])
assert.Equal(t, "pr-author", result.TopAchievers["pull_requests"])
assert.Equal(t, "reviewer", result.TopAchievers["reviews"])
// Overall top achiever has highest score
assert.NotEmpty(t, result.TopAchievers["overall"])
}
func TestCalculator_TeamScoring(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
Color: "#ff0000",
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 30, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
Teams: []models.TeamMetrics{
{
Name: "Backend Team",
Members: []string{"user1", "user2"},
MemberMetrics: []models.ContributorMetrics{
{Login: "user1"},
{Login: "user2"},
},
},
},
}
result := calc.Calculate(metrics)
require.Len(t, result.Teams, 1)
team := result.Teams[0]
// Total: 500 + 300 = 800
assert.Equal(t, 800, team.TotalScore)
// Avg: 800 / 2 = 400
assert.Equal(t, 400.0, team.AvgScore)
// Check individual member scores
assert.Equal(t, 500, team.MemberMetrics[0].Score.Total)
assert.Equal(t, 300, team.MemberMetrics[1].Score.Total)
}
func TestCalculator_TeamInLeaderboard(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
cfg.Teams = []config.TeamConfig{
{
Name: "Backend Team",
Members: []string{"user1"},
},
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo"}},
{Login: "user2", CommitCount: 30, RepositoriesContributed: []string{"owner/repo"}},
},
},
},
}
result := calc.Calculate(metrics)
// user1 should have team name in leaderboard
assert.Equal(t, "Backend Team", result.Leaderboard[0].Team)
// user2 should not have a team
assert.Empty(t, result.Leaderboard[1].Team)
}
func TestCalculator_DetermineTopCategory(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
tests := []struct {
name string
contributor models.ContributorMetrics
expectedCategory string
}{
{
name: "Top committer",
contributor: models.ContributorMetrics{
CommitCount: 100,
PRsOpened: 10,
ReviewsGiven: 5,
ReviewComments: 20,
},
expectedCategory: "Commits",
},
{
name: "Top PR author",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 100,
ReviewsGiven: 5,
ReviewComments: 20,
},
expectedCategory: "PRs",
},
{
name: "Top reviewer",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 5,
ReviewsGiven: 100,
ReviewComments: 20,
},
expectedCategory: "Reviews",
},
{
name: "Top commenter",
contributor: models.ContributorMetrics{
CommitCount: 10,
PRsOpened: 5,
ReviewsGiven: 20,
ReviewComments: 100,
},
expectedCategory: "Comments",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := calc.determineTopCategory(&tt.contributor)
assert.Equal(t, tt.expectedCategory, result)
})
}
}
func TestCalculator_MultipleRepositories(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{Commit: 10}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo1",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 50, RepositoriesContributed: []string{"owner/repo1"}},
},
},
{
FullName: "owner/repo2",
Contributors: []models.ContributorMetrics{
{Login: "user1", CommitCount: 30, RepositoriesContributed: []string{"owner/repo2"}},
},
},
},
}
result := calc.Calculate(metrics)
// Should aggregate commits from both repos
require.Len(t, result.Leaderboard, 1)
// 50 + 30 = 80 commits * 10 = 800
assert.Equal(t, 800, result.Leaderboard[0].Score)
// Both repos should be tracked
contributor := result.Repositories[0].Contributors[0]
assert.Equal(t, 800, contributor.Score.Total)
}
func TestCalculator_EmptyMetrics(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{},
}
result := calc.Calculate(metrics)
assert.Empty(t, result.Leaderboard)
assert.Empty(t, result.TopAchievers)
}
func TestCalculator_NoReviewsNoBonus(t *testing.T) {
t.Parallel()
cfg := config.DefaultConfig()
cfg.Scoring.Enabled = true
cfg.Scoring.Points = config.PointsConfig{
FastReview1h: 50,
}
calc := NewCalculator(cfg)
metrics := &models.GlobalMetrics{
Repositories: []models.RepositoryMetrics{
{
FullName: "owner/repo",
Contributors: []models.ContributorMetrics{
{
Login: "user1",
ReviewsGiven: 0,
AvgReviewTime: 0.5, // Fast but no reviews
RepositoriesContributed: []string{"owner/repo"},
},
},
},
},
}
result := calc.Calculate(metrics)
contributor := result.Repositories[0].Contributors[0]
// Should not get bonus if no reviews given
assert.Equal(t, 0, contributor.Score.Breakdown.ResponseBonus)
}
func TestContains(t *testing.T) {
t.Parallel()
slice := []string{"a", "b", "c"}
assert.True(t, contains(slice, "a"))
assert.True(t, contains(slice, "b"))
assert.True(t, contains(slice, "c"))
assert.False(t, contains(slice, "d"))
assert.False(t, contains([]string{}, "a"))
}
+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)
}
}
+440
View File
@@ -0,0 +1,440 @@
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
)
// ProgressCallback is called to report progress during git operations
type ProgressCallback func(message string)
// Repository manages local git repository operations using go-git
type Repository struct {
baseDir string
progress ProgressCallback
}
// NewRepository creates a new repository manager
func NewRepository(baseDir string) (*Repository, error) {
// Create base directory if it doesn't exist
if err := os.MkdirAll(baseDir, 0750); err != nil {
return nil, fmt.Errorf("failed to create base directory: %w", err)
}
return &Repository{
baseDir: baseDir,
progress: func(string) {}, // no-op by default
}, nil
}
// SetProgressCallback sets the callback function for progress reporting
func (r *Repository) SetProgressCallback(cb ProgressCallback) {
if cb != nil {
r.progress = cb
}
}
// repoPath returns the local path for a repository
func (r *Repository) repoPath(owner, name string) string {
return filepath.Join(r.baseDir, owner, name)
}
// EnsureCloned ensures a repository is cloned and up to date
func (r *Repository) EnsureCloned(ctx context.Context, owner, name, token string) error {
repoPath := r.repoPath(owner, name)
// Check if already cloned
gitDir := filepath.Join(repoPath, ".git")
if _, err := os.Stat(gitDir); err == nil {
// Repository exists, fetch latest
r.progress(fmt.Sprintf(" Updating local clone of %s/%s...", owner, name))
return r.fetch(ctx, repoPath, token)
}
// Clone the repository
r.progress(fmt.Sprintf(" Cloning %s/%s...", owner, name))
return r.clone(ctx, owner, name, token, repoPath)
}
// clone clones a repository using go-git
func (r *Repository) clone(ctx context.Context, owner, name, token, destPath string) error {
// Create parent directory
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
return fmt.Errorf("failed to create parent directory: %w", err)
}
cloneURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, name)
cloneOpts := &git.CloneOptions{
URL: cloneURL,
Progress: nil, // Could add progress writer here
}
// Add authentication if token provided
if token != "" {
cloneOpts.Auth = &http.BasicAuth{
Username: "x-access-token",
Password: token,
}
}
_, err := git.PlainCloneContext(ctx, destPath, false, cloneOpts)
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
return nil
}
// fetch fetches latest changes from remote using go-git
func (r *Repository) fetch(ctx context.Context, repoPath, token string) error {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return fmt.Errorf("failed to open repository: %w", err)
}
fetchOpts := &git.FetchOptions{
RemoteName: "origin",
Force: true,
Prune: true,
RefSpecs: []config.RefSpec{"+refs/*:refs/*"},
}
// Add authentication if token provided
if token != "" {
fetchOpts.Auth = &http.BasicAuth{
Username: "x-access-token",
Password: token,
}
}
err = repo.FetchContext(ctx, fetchOpts)
if err != nil && err != git.NoErrAlreadyUpToDate {
return fmt.Errorf("failed to fetch: %w", err)
}
return nil
}
// isCommentLine checks if a line is a code comment (should not count as contribution)
func isCommentLine(line string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return true // Empty lines don't count
}
// Common comment patterns across languages
commentPrefixes := []string{
"//", // C, C++, Java, Go, JS, etc.
"#", // Python, Ruby, Shell, YAML
"/*", // C-style block comment start
"*/", // C-style block comment end
"*", // C-style block comment continuation
"<!--", // HTML/XML comment
"-->", // HTML/XML comment end
"--", // SQL, Lua, Haskell
";", // Assembly, Lisp, INI files
"'", // VB comment
"\"\"\"", // Python docstring
"'''", // Python docstring
}
for _, prefix := range commentPrefixes {
if strings.HasPrefix(trimmed, prefix) {
return true
}
}
return false
}
// isDocumentationFile checks if a file is documentation-only
func isDocumentationFile(filename string) bool {
// Documentation file extensions and patterns
docPatterns := []string{
".md", ".markdown", ".rst", ".txt", ".adoc",
"README", "CHANGELOG", "LICENSE", "CONTRIBUTING",
"docs/", "documentation/", "/doc/",
}
lowerFilename := strings.ToLower(filename)
for _, pattern := range docPatterns {
if strings.Contains(lowerFilename, strings.ToLower(pattern)) {
return true
}
}
return false
}
// FetchCommits retrieves commits from the local repository using go-git
func (r *Repository) FetchCommits(ctx context.Context, owner, name string, since, until *time.Time) ([]models.Commit, error) {
repoPath := r.repoPath(owner, name)
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
r.progress(" Iterating commits with go-git...")
// Get all references to iterate all branches
refs, err := repo.References()
if err != nil {
return nil, fmt.Errorf("failed to get references: %w", err)
}
// Collect all commit hashes from all branches
seenCommits := make(map[plumbing.Hash]bool)
var commits []models.Commit
testPatterns := []string{"_test.go", ".test.", ".spec.", "/tests/", "/test/", "__tests__"}
err = refs.ForEach(func(ref *plumbing.Reference) error {
// Skip non-branch references
if !ref.Name().IsBranch() && !ref.Name().IsRemote() && !ref.Name().IsTag() {
return nil
}
// Get commit iterator for this reference
commitIter, err := repo.Log(&git.LogOptions{
From: ref.Hash(),
Order: git.LogOrderCommitterTime,
All: false,
})
if err != nil {
// Skip refs that don't point to commits
return nil
}
err = commitIter.ForEach(func(c *object.Commit) error {
// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Skip already seen commits
if seenCommits[c.Hash] {
return nil
}
seenCommits[c.Hash] = true
commitTime := c.Author.When
// Filter by date range
if since != nil && commitTime.Before(*since) {
return nil
}
if until != nil && commitTime.After(*until) {
return nil
}
// Get file stats for this commit
additions, deletions, filesChanged, hasTests := r.getCommitStats(c, testPatterns)
// Extract login from email
authorLogin := extractLoginFromEmail(c.Author.Email, c.Author.Name)
committerLogin := extractLoginFromEmail(c.Committer.Email, c.Committer.Name)
commit := models.Commit{
SHA: c.Hash.String(),
Message: strings.Split(c.Message, "\n")[0], // First line only
Author: models.Author{
Login: authorLogin,
Name: c.Author.Name,
Email: c.Author.Email,
},
Committer: models.Author{
Login: committerLogin,
Name: c.Committer.Name,
Email: c.Committer.Email,
},
Date: commitTime,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, name),
URL: fmt.Sprintf("https://github.com/%s/%s/commit/%s", owner, name, c.Hash.String()),
HasTests: hasTests,
}
commits = append(commits, commit)
return nil
})
return err
})
if err != nil {
return nil, fmt.Errorf("failed to iterate commits: %w", err)
}
r.progress(fmt.Sprintf(" Found %d commits", len(commits)))
return commits, nil
}
// getCommitStats calculates additions, deletions, files changed for a commit
func (r *Repository) getCommitStats(c *object.Commit, testPatterns []string) (additions, deletions, filesChanged int, hasTests bool) {
// Get parent commit for diff
parentIter := c.Parents()
parent, err := parentIter.Next()
var parentTree *object.Tree
if err == nil {
parentTree, _ = parent.Tree()
}
currentTree, err := c.Tree()
if err != nil {
return 0, 0, 0, false
}
// Get changes between parent and current
var changes object.Changes
if parentTree != nil {
changes, err = parentTree.Diff(currentTree)
} else {
// Initial commit - all files are additions
changes, err = object.DiffTree(nil, currentTree)
}
if err != nil {
return 0, 0, 0, false
}
filesSet := make(map[string]bool)
for _, change := range changes {
// Get the file path
var filePath string
if change.To.Name != "" {
filePath = change.To.Name
} else if change.From.Name != "" {
filePath = change.From.Name
}
// Skip documentation files
if isDocumentationFile(filePath) {
continue
}
// Count unique files
if !filesSet[filePath] {
filesSet[filePath] = true
filesChanged++
// Check for test files
for _, pattern := range testPatterns {
if strings.Contains(filePath, pattern) {
hasTests = true
break
}
}
}
// Get patch to count lines
patch, err := change.Patch()
if err != nil {
continue
}
for _, filePatch := range patch.FilePatches() {
for _, chunk := range filePatch.Chunks() {
content := chunk.Content()
lines := strings.Split(content, "\n")
switch chunk.Type() {
case 1: // Add
for _, line := range lines {
if !isCommentLine(line) {
additions++
}
}
case 2: // Delete
for _, line := range lines {
if !isCommentLine(line) {
deletions++
}
}
}
}
}
}
return additions, deletions, filesChanged, hasTests
}
// extractLoginFromEmail tries to extract GitHub login from email
func extractLoginFromEmail(email, fallbackName string) string {
// Pattern: 12345678+username@users.noreply.github.com
// or: username@users.noreply.github.com
if strings.Contains(email, "@users.noreply.github.com") {
localPart := strings.Split(email, "@")[0]
// Remove numeric prefix if present (e.g., "12345678+username")
if idx := strings.Index(localPart, "+"); idx != -1 {
return localPart[idx+1:]
}
return localPart
}
// Fallback: use sanitized name as login
login := strings.ToLower(fallbackName)
login = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(login, "-")
return login
}
// GetAuthorMappings fetches author login mappings
// This helps map commit authors to GitHub usernames
func (r *Repository) GetAuthorMappings(ctx context.Context, owner, name string) (map[string]string, error) {
repoPath := r.repoPath(owner, name)
repo, err := git.PlainOpen(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
mappings := make(map[string]string)
// Iterate all commits to collect author mappings
commitIter, err := repo.Log(&git.LogOptions{All: true})
if err != nil {
return nil, fmt.Errorf("failed to get commit log: %w", err)
}
err = commitIter.ForEach(func(c *object.Commit) error {
if _, exists := mappings[c.Author.Email]; !exists {
mappings[c.Author.Email] = extractLoginFromEmail(c.Author.Email, c.Author.Name)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate commits: %w", err)
}
return mappings, nil
}
// Cleanup removes the local clone of a repository
func (r *Repository) Cleanup(owner, name string) error {
repoPath := r.repoPath(owner, name)
return os.RemoveAll(repoPath)
}
// CleanupAll removes all local clones
func (r *Repository) CleanupAll() error {
return os.RemoveAll(r.baseDir)
}
+217
View File
@@ -0,0 +1,217 @@
package cache
import (
"crypto/sha256"
"encoding/gob"
"encoding/hex"
"os"
"path/filepath"
"sync"
"time"
)
// Cache defines the interface for caching
type Cache interface {
Get(key string) (interface{}, bool)
Set(key string, value interface{})
Delete(key string)
Clear() error
}
// FileCache implements file-based caching
type FileCache struct {
directory string
ttl time.Duration
mu sync.RWMutex
}
// cacheEntry wraps a cached value with expiration
type cacheEntry struct {
Value interface{}
ExpiresAt time.Time
}
// NewFileCache creates a new file-based cache
func NewFileCache(directory string, ttl time.Duration) (*FileCache, error) {
// Create directory if it doesn't exist
if err := os.MkdirAll(directory, 0750); err != nil {
return nil, err
}
return &FileCache{
directory: directory,
ttl: ttl,
}, nil
}
// Get retrieves a value from the cache
func (c *FileCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
path := c.keyToPath(key)
file, err := os.Open(path) // #nosec G304 -- path is internally generated hash
if err != nil {
return nil, false
}
defer file.Close()
var entry cacheEntry
decoder := gob.NewDecoder(file)
if err := decoder.Decode(&entry); err != nil {
return nil, false
}
// Check expiration
if time.Now().After(entry.ExpiresAt) {
_ = os.Remove(path)
return nil, false
}
return entry.Value, true
}
// Set stores a value in the cache
func (c *FileCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
entry := cacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
path := c.keyToPath(key)
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return
}
file, err := os.Create(path) // #nosec G304 -- path is internally generated hash
if err != nil {
return
}
defer file.Close()
encoder := gob.NewEncoder(file)
_ = encoder.Encode(entry)
}
// Delete removes a value from the cache
func (c *FileCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
path := c.keyToPath(key)
_ = os.Remove(path)
}
// Clear removes all cached values
func (c *FileCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
return os.RemoveAll(c.directory)
}
// keyToPath converts a cache key to a file path
func (c *FileCache) keyToPath(key string) string {
hash := sha256.Sum256([]byte(key))
filename := hex.EncodeToString(hash[:8]) + ".gob"
return filepath.Join(c.directory, filename)
}
// NoopCache is a cache that doesn't cache anything
type NoopCache struct{}
// NewNoopCache creates a new no-op cache
func NewNoopCache() *NoopCache {
return &NoopCache{}
}
// Get always returns false
func (c *NoopCache) Get(key string) (interface{}, bool) {
return nil, false
}
// Set does nothing
func (c *NoopCache) Set(key string, value interface{}) {}
// Delete does nothing
func (c *NoopCache) Delete(key string) {}
// Clear does nothing
func (c *NoopCache) Clear() error {
return nil
}
// MemoryCache implements in-memory caching (useful for testing)
type MemoryCache struct {
data map[string]cacheEntry
ttl time.Duration
mu sync.RWMutex
}
// NewMemoryCache creates a new in-memory cache
func NewMemoryCache(ttl time.Duration) *MemoryCache {
return &MemoryCache{
data: make(map[string]cacheEntry),
ttl: ttl,
}
}
// Get retrieves a value from the cache
func (c *MemoryCache) Get(key string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.data[key]
if !ok {
return nil, false
}
// Check expiration
if time.Now().After(entry.ExpiresAt) {
delete(c.data, key)
return nil, false
}
return entry.Value, true
}
// Set stores a value in the cache
func (c *MemoryCache) Set(key string, value interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = cacheEntry{
Value: value,
ExpiresAt: time.Now().Add(c.ttl),
}
}
// Delete removes a value from the cache
func (c *MemoryCache) Delete(key string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.data, key)
}
// Clear removes all cached values
func (c *MemoryCache) Clear() error {
c.mu.Lock()
defer c.mu.Unlock()
c.data = make(map[string]cacheEntry)
return nil
}
// Register types for gob encoding
func init() {
// Register common types that might be cached
gob.Register([]interface{}{})
gob.Register(map[string]interface{}{})
}
+290
View File
@@ -0,0 +1,290 @@
package cache
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFileCache_Basic(t *testing.T) {
// Create temp directory for cache
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Test Set and Get
cache.Set("test-key", "test-value")
value, ok := cache.Get("test-key")
assert.True(t, ok)
assert.Equal(t, "test-value", value)
}
func TestFileCache_GetNonExistent(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
value, ok := cache.Get("non-existent")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Expiration(t *testing.T) {
tempDir := t.TempDir()
// Use a very short TTL
cache, err := NewFileCache(tempDir, 50*time.Millisecond)
require.NoError(t, err)
cache.Set("expire-key", "expire-value")
// Should be available immediately
value, ok := cache.Get("expire-key")
assert.True(t, ok)
assert.Equal(t, "expire-value", value)
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
value, ok = cache.Get("expire-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Delete(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
cache.Set("delete-key", "delete-value")
// Verify it exists
_, ok := cache.Get("delete-key")
assert.True(t, ok)
// Delete it
cache.Delete("delete-key")
// Should be gone
value, ok := cache.Get("delete-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestFileCache_Clear(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Add multiple entries
cache.Set("key1", "value1")
cache.Set("key2", "value2")
cache.Set("key3", "value3")
// Clear the cache
err = cache.Clear()
require.NoError(t, err)
// All should be gone
_, ok := cache.Get("key1")
assert.False(t, ok)
_, ok = cache.Get("key2")
assert.False(t, ok)
_, ok = cache.Get("key3")
assert.False(t, ok)
}
func TestFileCache_ComplexValues(t *testing.T) {
tempDir := t.TempDir()
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Test with map
mapValue := map[string]interface{}{
"key1": "value1",
"key2": 123,
}
cache.Set("map-key", mapValue)
retrieved, ok := cache.Get("map-key")
assert.True(t, ok)
assert.Equal(t, mapValue, retrieved)
// Test with slice
sliceValue := []interface{}{"a", "b", "c"}
cache.Set("slice-key", sliceValue)
retrieved, ok = cache.Get("slice-key")
assert.True(t, ok)
assert.Equal(t, sliceValue, retrieved)
}
func TestFileCache_CreateDirectory(t *testing.T) {
// Test that NewFileCache creates directory if it doesn't exist
tempDir := filepath.Join(t.TempDir(), "nested", "cache", "dir")
cache, err := NewFileCache(tempDir, time.Hour)
require.NoError(t, err)
// Verify directory was created
info, err := os.Stat(tempDir)
require.NoError(t, err)
assert.True(t, info.IsDir())
// Should be usable
cache.Set("key", "value")
value, ok := cache.Get("key")
assert.True(t, ok)
assert.Equal(t, "value", value)
}
func TestMemoryCache_Basic(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
// Test Set and Get
cache.Set("test-key", "test-value")
value, ok := cache.Get("test-key")
assert.True(t, ok)
assert.Equal(t, "test-value", value)
}
func TestMemoryCache_GetNonExistent(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
value, ok := cache.Get("non-existent")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Expiration(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(50 * time.Millisecond)
cache.Set("expire-key", "expire-value")
// Should be available immediately
value, ok := cache.Get("expire-key")
assert.True(t, ok)
assert.Equal(t, "expire-value", value)
// Wait for expiration
time.Sleep(100 * time.Millisecond)
// Should be expired now
value, ok = cache.Get("expire-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Delete(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
cache.Set("delete-key", "delete-value")
// Verify it exists
_, ok := cache.Get("delete-key")
assert.True(t, ok)
// Delete it
cache.Delete("delete-key")
// Should be gone
value, ok := cache.Get("delete-key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestMemoryCache_Clear(t *testing.T) {
t.Parallel()
cache := NewMemoryCache(time.Hour)
// Add multiple entries
cache.Set("key1", "value1")
cache.Set("key2", "value2")
cache.Set("key3", "value3")
// Clear the cache
err := cache.Clear()
require.NoError(t, err)
// All should be gone
_, ok := cache.Get("key1")
assert.False(t, ok)
_, ok = cache.Get("key2")
assert.False(t, ok)
_, ok = cache.Get("key3")
assert.False(t, ok)
}
func TestNoopCache_AlwaysReturnsFalse(t *testing.T) {
t.Parallel()
cache := NewNoopCache()
// Set something
cache.Set("key", "value")
// Get should return false
value, ok := cache.Get("key")
assert.False(t, ok)
assert.Nil(t, value)
}
func TestNoopCache_DeleteAndClear(t *testing.T) {
t.Parallel()
cache := NewNoopCache()
// These should not panic or error
cache.Delete("key")
err := cache.Clear()
assert.NoError(t, err)
}
func TestFileCache_KeyToPath(t *testing.T) {
t.Parallel()
cache := &FileCache{directory: "/tmp/cache"}
path1 := cache.keyToPath("key1")
path2 := cache.keyToPath("key2")
path1Again := cache.keyToPath("key1")
// Different keys should produce different paths
assert.NotEqual(t, path1, path2)
// Same key should produce same path
assert.Equal(t, path1, path1Again)
// Path should end with .gob
assert.Contains(t, path1, ".gob")
}
func TestCacheInterface(t *testing.T) {
t.Parallel()
// Ensure all cache types implement the interface
var _ Cache = (*FileCache)(nil)
var _ Cache = (*MemoryCache)(nil)
var _ Cache = (*NoopCache)(nil)
}
+928
View File
@@ -0,0 +1,928 @@
package github
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/google/go-github/v68/github"
"github.com/lukaszraczylo/git-velocity/internal/config"
"github.com/lukaszraczylo/git-velocity/internal/domain/models"
"github.com/lukaszraczylo/git-velocity/internal/github/cache"
)
// ProgressCallback is called to report progress during API operations
type ProgressCallback func(message string)
// RetryConfig holds retry settings
type RetryConfig struct {
MaxRetries int
InitialBackoff time.Duration
MaxBackoff time.Duration
}
// DefaultRetryConfig returns the default retry configuration
func DefaultRetryConfig() RetryConfig {
return RetryConfig{
MaxRetries: 3,
InitialBackoff: 1 * time.Second,
MaxBackoff: 30 * time.Second,
}
}
// Client wraps the GitHub API client with rate limiting and caching
type Client struct {
gh *github.Client
config *config.Config
cache cache.Cache
retry RetryConfig
progress ProgressCallback
}
// NewClient creates a new GitHub client with the appropriate authentication
func NewClient(ctx context.Context, cfg *config.Config) (*Client, error) {
var gh *github.Client
// Determine authentication method
if cfg.HasGithubToken() {
gh = github.NewClient(nil).WithAuthToken(cfg.Auth.GithubToken)
} else if cfg.HasGithubApp() {
// GitHub App authentication
privateKey, err := cfg.GetGithubAppPrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to get GitHub App private key: %w", err)
}
itr, err := ghinstallation.New(
http.DefaultTransport,
cfg.Auth.GithubApp.AppID,
cfg.Auth.GithubApp.InstallationID,
privateKey,
)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub App transport: %w", err)
}
gh = github.NewClient(&http.Client{Transport: itr})
} else {
return nil, fmt.Errorf("no authentication method configured")
}
// Initialize cache
var c cache.Cache
if cfg.Cache.Enabled {
ttl, err := cfg.GetCacheTTL()
if err != nil {
return nil, fmt.Errorf("failed to parse cache TTL: %w", err)
}
c, err = cache.NewFileCache(cfg.Cache.Directory, ttl)
if err != nil {
return nil, fmt.Errorf("failed to initialize cache: %w", err)
}
} else {
c = cache.NewNoopCache()
}
return &Client{
gh: gh,
config: cfg,
cache: c,
retry: DefaultRetryConfig(),
progress: func(string) {}, // no-op by default
}, nil
}
// SetProgressCallback sets the callback function for progress reporting
func (c *Client) SetProgressCallback(cb ProgressCallback) {
if cb != nil {
c.progress = cb
}
}
// SetRetryConfig sets the retry configuration
func (c *Client) SetRetryConfig(rc RetryConfig) {
c.retry = rc
}
// retryWithBackoff executes a function with retry logic
// - For rate limit errors: waits until the limit resets (no retry count limit)
// - For network/transient errors: uses exponential backoff with MaxRetries limit
func (c *Client) retryWithBackoff(ctx context.Context, operation string, fn func() error) error {
var lastErr error
backoff := c.retry.InitialBackoff
networkRetries := 0
for {
lastErr = fn()
if lastErr == nil {
return nil
}
// Check if error is retryable at all
if !isRetryableError(lastErr) {
return lastErr
}
c.progress(fmt.Sprintf(" %s failed: %v", operation, lastErr))
// Determine wait strategy based on error type
if resetTime := getRateLimitResetTime(lastErr); resetTime != nil {
// Rate limit error - wait until reset, no retry count limit
waitDuration := time.Until(*resetTime) + time.Second // Add 1s buffer
if waitDuration < 0 {
waitDuration = time.Second
}
c.progress(fmt.Sprintf(" Rate limit hit. Waiting until %s (%s)...", resetTime.Format("15:04:05"), waitDuration.Round(time.Second)))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(waitDuration):
}
// Reset network retry counter after successful rate limit wait
networkRetries = 0
backoff = c.retry.InitialBackoff
} else {
// Network/transient error - use exponential backoff with retry limit
networkRetries++
if networkRetries > c.retry.MaxRetries {
return fmt.Errorf("%s failed after %d retries: %w", operation, c.retry.MaxRetries, lastErr)
}
c.progress(fmt.Sprintf(" Retry %d/%d for %s (waiting %s)...", networkRetries, c.retry.MaxRetries, operation, backoff))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
backoff *= 2
if backoff > c.retry.MaxBackoff {
backoff = c.retry.MaxBackoff
}
}
}
}
// getRateLimitResetTime extracts the reset time from rate limit errors
func getRateLimitResetTime(err error) *time.Time {
if err == nil {
return nil
}
var rateLimitErr *github.RateLimitError
if errors.As(err, &rateLimitErr) && rateLimitErr.Rate.Reset.Time.After(time.Now()) {
t := rateLimitErr.Rate.Reset.Time
return &t
}
var abuseErr *github.AbuseRateLimitError
if errors.As(err, &abuseErr) && abuseErr.RetryAfter != nil {
t := time.Now().Add(*abuseErr.RetryAfter)
return &t
}
return nil
}
// isRetryableError checks if an error is retryable
func isRetryableError(err error) bool {
if err == nil {
return false
}
// Network errors (timeout only - Temporary() is deprecated)
var netErr net.Error
if errors.As(err, &netErr) {
return netErr.Timeout()
}
// GitHub rate limit errors
var rateLimitErr *github.RateLimitError
if errors.As(err, &rateLimitErr) {
return true
}
// GitHub abuse rate limit errors
var abuseErr *github.AbuseRateLimitError
if errors.As(err, &abuseErr) {
return true
}
// Check error message for common transient errors
errStr := err.Error()
retryableMessages := []string{
"connection reset",
"connection refused",
"timeout",
"temporary failure",
"server error",
"502",
"503",
"504",
}
for _, msg := range retryableMessages {
if strings.Contains(strings.ToLower(errStr), msg) {
return true
}
}
return false
}
// ListOrgRepos lists repositories in an organization matching a pattern
func (c *Client) ListOrgRepos(ctx context.Context, org, pattern string) ([]string, error) {
var allRepos []string
opts := &github.RepositoryListByOrgOptions{
Type: "all",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
for {
repos, resp, err := c.gh.Repositories.ListByOrg(ctx, org, opts)
if err != nil {
return nil, fmt.Errorf("failed to list org repos: %w", err)
}
for _, repo := range repos {
name := repo.GetName()
if matchPattern(name, pattern) {
allRepos = append(allRepos, name)
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allRepos, nil
}
// FetchCommits fetches commits from a repository within a date range
func (c *Client) FetchCommits(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Commit, error) {
cacheKey := fmt.Sprintf("commits:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if commits, ok := cached.([]models.Commit); ok {
c.progress(" Using cached commits data")
return commits, nil
}
}
var allCommits []models.Commit
opts := &github.CommitsListOptions{
ListOptions: github.ListOptions{PerPage: 100},
}
if since != nil {
opts.Since = *since
}
if until != nil {
opts.Until = *until
}
page := 1
for {
var commits []*github.RepositoryCommit
var resp *github.Response
err := c.retryWithBackoff(ctx, "list commits", func() error {
var err error
commits, resp, err = c.gh.Repositories.ListCommits(ctx, owner, repo, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list commits: %w", err)
}
c.progress(fmt.Sprintf(" Fetching commits page %d (%d commits so far)...", page, len(allCommits)))
for i, commit := range commits {
// Fetch detailed commit info for stats
var detailed *github.RepositoryCommit
err := c.retryWithBackoff(ctx, fmt.Sprintf("get commit %s", commit.GetSHA()[:7]), func() error {
var err error
detailed, _, err = c.gh.Repositories.GetCommit(ctx, owner, repo, commit.GetSHA(), nil)
return err
})
if err != nil {
// Log and continue - we can still use basic info
c.progress(fmt.Sprintf(" Warning: failed to get commit details for %s: %v", commit.GetSHA()[:7], err))
continue
}
mc := convertCommit(detailed, owner, repo)
allCommits = append(allCommits, mc)
// Progress every 10 commits
if (i+1)%10 == 0 {
c.progress(fmt.Sprintf(" Processing commit %d/%d on page %d...", i+1, len(commits), page))
}
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
// Cache results
c.cache.Set(cacheKey, allCommits)
return allCommits, nil
}
// mainBranches are the branches we consider as "main" branches
var mainBranches = []string{"main", "master", "develop", "dev"}
// FetchPullRequests fetches pull requests from a repository
// Fetches PRs targeting main branches, filters by merge date
func (c *Client) FetchPullRequests(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.PullRequest, error) {
cacheKey := fmt.Sprintf("prs:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if prs, ok := cached.([]models.PullRequest); ok {
c.progress(" Using cached pull requests data")
return prs, nil
}
}
var allPRs []models.PullRequest
// Fetch PRs for each main branch separately (API supports base filter)
for _, baseBranch := range mainBranches {
prs, err := c.fetchPRsForBranch(ctx, owner, repo, baseBranch, since, until)
if err != nil {
// Branch might not exist, skip
continue
}
allPRs = append(allPRs, prs...)
}
c.progress(fmt.Sprintf(" Found %d merged PRs to main branches in date range", len(allPRs)))
// Cache results
c.cache.Set(cacheKey, allPRs)
return allPRs, nil
}
// fetchPRsForBranch fetches merged PRs for a specific base branch
func (c *Client) fetchPRsForBranch(ctx context.Context, owner, repo, baseBranch string, since, until *time.Time) ([]models.PullRequest, error) {
var branchPRs []models.PullRequest
opts := &github.PullRequestListOptions{
State: "closed",
Base: baseBranch, // Filter by base branch at API level
Sort: "updated",
Direction: "desc",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
page := 1
consecutiveOldPages := 0
for {
var prs []*github.PullRequest
var resp *github.Response
err := c.retryWithBackoff(ctx, "list pull requests", func() error {
var err error
prs, resp, err = c.gh.PullRequests.List(ctx, owner, repo, opts)
return err
})
if err != nil {
return branchPRs, err
}
if page == 1 && len(prs) > 0 {
c.progress(fmt.Sprintf(" Fetching PRs for branch '%s'...", baseBranch))
}
matchedInPage := 0
oldInPage := 0
for _, pr := range prs {
// Only consider merged PRs (check MergedAt since Merged field isn't in list response)
if pr.MergedAt == nil {
continue
}
// Use merge date for filtering
mergedAt := pr.MergedAt.Time
// Skip items newer than our range
if until != nil && mergedAt.After(*until) {
continue
}
// If older than our range, track it
if since != nil && mergedAt.Before(*since) {
oldInPage++
continue
}
mpr := convertPullRequest(pr, owner, repo)
branchPRs = append(branchPRs, mpr)
matchedInPage++
}
// Early termination: if we got a page with only old PRs (or empty), increment counter
if matchedInPage == 0 && oldInPage > 0 {
consecutiveOldPages++
// Stop after 2 consecutive pages of only old PRs
if consecutiveOldPages >= 2 {
break
}
} else {
consecutiveOldPages = 0
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
page++
}
return branchPRs, nil
}
// FetchReviews fetches reviews for a specific pull request
func (c *Client) FetchReviews(ctx context.Context, owner, repo string, prNumber int) ([]models.Review, error) {
cacheKey := fmt.Sprintf("reviews:%s/%s:%d", owner, repo, prNumber)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if reviews, ok := cached.([]models.Review); ok {
return reviews, nil
}
}
var allReviews []models.Review
opts := &github.ListOptions{PerPage: 100}
for {
var reviews []*github.PullRequestReview
var resp *github.Response
err := c.retryWithBackoff(ctx, fmt.Sprintf("list reviews for PR #%d", prNumber), func() error {
var err error
reviews, resp, err = c.gh.PullRequests.ListReviews(ctx, owner, repo, prNumber, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list reviews: %w", err)
}
for _, review := range reviews {
mr := convertReview(review, owner, repo, prNumber)
allReviews = append(allReviews, mr)
}
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
// Cache results
c.cache.Set(cacheKey, allReviews)
return allReviews, nil
}
// FetchIssues fetches issues from a repository
// Uses early termination when sorted by date - stops when items are outside date range
func (c *Client) FetchIssues(ctx context.Context, owner, repo string, since, until *time.Time) ([]models.Issue, error) {
cacheKey := fmt.Sprintf("issues:%s/%s:%v:%v", owner, repo, since, until)
// Check cache
if cached, ok := c.cache.Get(cacheKey); ok {
if issues, ok := cached.([]models.Issue); ok {
c.progress(" Using cached issues data")
return issues, nil
}
}
var allIssues []models.Issue
// Sort by created date descending - newest first
// This allows us to stop early when we hit items older than our date range
opts := &github.IssueListByRepoOptions{
State: "all",
Sort: "created",
Direction: "desc",
ListOptions: github.ListOptions{
PerPage: 100,
},
}
// Note: GitHub Issues API has a 'since' parameter but it filters by update time, not created time
// So we use our own filtering with early termination for better control
page := 1
reachedOldItems := false
for {
var issues []*github.Issue
var resp *github.Response
err := c.retryWithBackoff(ctx, "list issues", func() error {
var err error
issues, resp, err = c.gh.Issues.ListByRepo(ctx, owner, repo, opts)
return err
})
if err != nil {
return nil, fmt.Errorf("failed to list issues: %w", err)
}
c.progress(fmt.Sprintf(" Fetching issues page %d (%d issues so far)...", page, len(allIssues)))
oldItemsInPage := 0
totalNonPRItems := 0
for _, issue := range issues {
// Skip pull requests (they appear in issues API)
if issue.PullRequestLinks != nil {
continue
}
totalNonPRItems++
createdAt := issue.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
}
mi := convertIssue(issue, owner, repo)
allIssues = append(allIssues, mi)
}
// If all non-PR items in this page are older than our range, we can stop
// (since results are sorted by created date descending)
if oldItemsInPage == totalNonPRItems && totalNonPRItems > 0 {
c.progress(fmt.Sprintf(" Reached issues 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 issues", page))
}
// Cache results
c.cache.Set(cacheKey, allIssues)
return allIssues, nil
}
// UserProfile contains GitHub user profile information useful for deduplication
type UserProfile struct {
ID int64 // GitHub user ID
Login string // GitHub username
Name string // Display name
Email string // Public email (may be empty)
AvatarURL string
}
// FetchUserProfiles fetches GitHub profiles for a list of logins
// This is useful for deduplication by getting user IDs, names, and public emails
func (c *Client) FetchUserProfiles(ctx context.Context, logins []string) (map[string]UserProfile, error) {
profiles := make(map[string]UserProfile)
// Use semaphore to limit concurrent requests
sem := make(chan struct{}, 10)
results := make(chan struct {
login string
profile UserProfile
err error
}, len(logins))
for _, login := range logins {
go func(login string) {
sem <- struct{}{}
defer func() { <-sem }()
cacheKey := fmt.Sprintf("user_profile_%s", login)
if cached, ok := c.cache.Get(cacheKey); ok {
if profile, ok := cached.(UserProfile); ok {
results <- struct {
login string
profile UserProfile
err error
}{login, profile, nil}
return
}
}
var profile UserProfile
err := c.retryWithBackoff(ctx, "fetch user profile", func() error {
user, _, err := c.gh.Users.Get(ctx, login)
if err != nil {
return err
}
profile = UserProfile{
ID: user.GetID(),
Login: user.GetLogin(),
Name: user.GetName(),
Email: user.GetEmail(),
AvatarURL: user.GetAvatarURL(),
}
return nil
})
if err == nil {
c.cache.Set(cacheKey, profile)
}
results <- struct {
login string
profile UserProfile
err error
}{login, profile, err}
}(login)
}
// Collect results
for range logins {
r := <-results
if r.err == nil {
profiles[r.login] = r.profile
}
}
return profiles, nil
}
// Helper functions
func convertCommit(c *github.RepositoryCommit, owner, repo string) models.Commit {
var author models.Author
if c.Author != nil {
author = models.Author{
Login: c.Author.GetLogin(),
AvatarURL: c.Author.GetAvatarURL(),
}
}
if c.Commit != nil && c.Commit.Author != nil {
author.Name = c.Commit.Author.GetName()
author.Email = c.Commit.Author.GetEmail()
}
var committer models.Author
if c.Committer != nil {
committer = models.Author{
Login: c.Committer.GetLogin(),
AvatarURL: c.Committer.GetAvatarURL(),
}
}
if c.Commit != nil && c.Commit.Committer != nil {
committer.Name = c.Commit.Committer.GetName()
committer.Email = c.Commit.Committer.GetEmail()
}
var date time.Time
if c.Commit != nil && c.Commit.Author != nil {
date = c.Commit.Author.GetDate().Time
}
var additions, deletions, filesChanged int
if c.Stats != nil {
additions = c.Stats.GetAdditions()
deletions = c.Stats.GetDeletions()
}
filesChanged = len(c.Files)
// Detect if commit includes tests
hasTests := false
for _, f := range c.Files {
filename := f.GetFilename()
if strings.Contains(filename, "_test.go") ||
strings.Contains(filename, ".test.") ||
strings.Contains(filename, ".spec.") ||
strings.Contains(filename, "/tests/") ||
strings.Contains(filename, "/test/") ||
strings.Contains(filename, "__tests__") {
hasTests = true
break
}
}
message := ""
if c.Commit != nil {
message = c.Commit.GetMessage()
}
return models.Commit{
SHA: c.GetSHA(),
Message: message,
Author: author,
Committer: committer,
Date: date,
Additions: additions,
Deletions: deletions,
FilesChanged: filesChanged,
Repository: fmt.Sprintf("%s/%s", owner, repo),
URL: c.GetHTMLURL(),
HasTests: hasTests,
}
}
func convertPullRequest(pr *github.PullRequest, owner, repo string) models.PullRequest {
var author models.Author
if pr.User != nil {
author = models.Author{
ID: pr.User.GetID(),
Login: pr.User.GetLogin(),
Name: pr.User.GetName(),
AvatarURL: pr.User.GetAvatarURL(),
}
}
state := models.PRStateOpen
if pr.GetMerged() {
state = models.PRStateMerged
} else if pr.GetState() == "closed" {
state = models.PRStateClosed
}
var mergedAt, closedAt *time.Time
if pr.MergedAt != nil {
t := pr.MergedAt.Time
mergedAt = &t
}
if pr.ClosedAt != nil {
t := pr.ClosedAt.Time
closedAt = &t
}
var baseBranch, headBranch string
if pr.Base != nil {
baseBranch = pr.Base.GetRef()
}
if pr.Head != nil {
headBranch = pr.Head.GetRef()
}
return models.PullRequest{
Number: pr.GetNumber(),
Title: pr.GetTitle(),
State: state,
Author: author,
Repository: fmt.Sprintf("%s/%s", owner, repo),
BaseBranch: baseBranch,
HeadBranch: headBranch,
CreatedAt: pr.GetCreatedAt().Time,
UpdatedAt: pr.GetUpdatedAt().Time,
MergedAt: mergedAt,
ClosedAt: closedAt,
Additions: pr.GetAdditions(),
Deletions: pr.GetDeletions(),
FilesChanged: pr.GetChangedFiles(),
CommitCount: pr.GetCommits(),
Comments: pr.GetComments() + pr.GetReviewComments(),
URL: pr.GetHTMLURL(),
}
}
func convertReview(r *github.PullRequestReview, owner, repo string, prNumber int) models.Review {
var author models.Author
if r.User != nil {
author = models.Author{
ID: r.User.GetID(),
Login: r.User.GetLogin(),
Name: r.User.GetName(),
AvatarURL: r.User.GetAvatarURL(),
}
}
state := models.ReviewState(r.GetState())
submittedAt := time.Time{}
if r.SubmittedAt != nil {
submittedAt = r.SubmittedAt.Time
}
return models.Review{
ID: r.GetID(),
PullRequest: prNumber,
Repository: fmt.Sprintf("%s/%s", owner, repo),
Author: author,
State: state,
SubmittedAt: submittedAt,
Body: r.GetBody(),
}
}
func convertIssue(i *github.Issue, owner, repo string) models.Issue {
var author models.Author
if i.User != nil {
author = models.Author{
Login: i.User.GetLogin(),
Name: i.User.GetName(),
AvatarURL: i.User.GetAvatarURL(),
}
}
state := models.IssueStateOpen
if i.GetState() == "closed" {
state = models.IssueStateClosed
}
var closedAt *time.Time
var closedBy *models.Author
if i.ClosedAt != nil {
t := i.ClosedAt.Time
closedAt = &t
}
if i.ClosedBy != nil {
cb := models.Author{
Login: i.ClosedBy.GetLogin(),
AvatarURL: i.ClosedBy.GetAvatarURL(),
}
closedBy = &cb
}
var labels []string
for _, l := range i.Labels {
labels = append(labels, l.GetName())
}
return models.Issue{
Number: i.GetNumber(),
Title: i.GetTitle(),
State: state,
Author: author,
Repository: fmt.Sprintf("%s/%s", owner, repo),
CreatedAt: i.GetCreatedAt().Time,
UpdatedAt: i.GetUpdatedAt().Time,
ClosedAt: closedAt,
ClosedBy: closedBy,
Comments: i.GetComments(),
Labels: labels,
URL: i.GetHTMLURL(),
}
}
// matchPattern performs simple glob-style pattern matching
func matchPattern(s, pattern string) bool {
if pattern == "*" {
return true
}
// Handle exact match
if !strings.Contains(pattern, "*") {
return s == pattern
}
// Handle prefix match (pattern*)
if strings.HasSuffix(pattern, "*") && !strings.HasPrefix(pattern, "*") {
return strings.HasPrefix(s, strings.TrimSuffix(pattern, "*"))
}
// Handle suffix match (*pattern)
if strings.HasPrefix(pattern, "*") && !strings.HasSuffix(pattern, "*") {
return strings.HasSuffix(s, strings.TrimPrefix(pattern, "*"))
}
// Handle contains match (*pattern*)
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") {
inner := strings.TrimPrefix(strings.TrimSuffix(pattern, "*"), "*")
return strings.Contains(s, inner)
}
return false
}
+77
View File
@@ -0,0 +1,77 @@
package server
import (
"fmt"
"net/http"
"os"
"path/filepath"
"time"
)
// Server is a simple HTTP server for previewing the generated site
type Server struct {
directory string
port string
}
// New creates a new preview server
func New(directory, port string) *Server {
return &Server{
directory: directory,
port: port,
}
}
// Start starts the HTTP server
func (s *Server) Start() error {
// Check if directory exists
if _, err := os.Stat(s.directory); os.IsNotExist(err) {
return fmt.Errorf("directory does not exist: %s", s.directory)
}
// Get absolute path
absPath, err := filepath.Abs(s.directory)
if err != nil {
return fmt.Errorf("failed to get absolute path: %w", err)
}
// Create file server with directory listing disabled for security
fs := http.FileServer(http.Dir(absPath))
// Wrap with middleware
handler := s.loggingMiddleware(s.cacheMiddleware(fs))
addr := fmt.Sprintf(":%s", s.port)
srv := &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: 15 * time.Second,
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
return srv.ListenAndServe()
}
// loggingMiddleware logs incoming requests
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("%s %s\n", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// cacheMiddleware adds cache headers for static assets
func (s *Server) cacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Disable caching for development
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// Add CORS headers for local development
w.Header().Set("Access-Control-Allow-Origin", "*")
next.ServeHTTP(w, r)
})
}
+209
View File
@@ -0,0 +1,209 @@
package server
import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Parallel()
s := New("/tmp/test", "8080")
assert.Equal(t, "/tmp/test", s.directory)
assert.Equal(t, "8080", s.port)
}
func TestServer_StartWithNonExistentDirectory(t *testing.T) {
t.Parallel()
s := New("/this/directory/does/not/exist", "8080")
err := s.Start()
assert.Error(t, err)
assert.Contains(t, err.Error(), "directory does not exist")
}
func TestServer_CacheMiddleware(t *testing.T) {
t.Parallel()
s := New(".", "8080")
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
// Wrap with cache middleware
wrapped := s.cacheMiddleware(handler)
// Make a test request
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
wrapped.ServeHTTP(rr, req)
// Check cache headers are set correctly
assert.Equal(t, "no-cache, no-store, must-revalidate", rr.Header().Get("Cache-Control"))
assert.Equal(t, "no-cache", rr.Header().Get("Pragma"))
assert.Equal(t, "0", rr.Header().Get("Expires"))
assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin"))
}
func TestServer_LoggingMiddleware(t *testing.T) {
t.Parallel()
s := New(".", "8080")
// Create a test handler
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Wrap with logging middleware
wrapped := s.loggingMiddleware(handler)
// Make a test request
req := httptest.NewRequest("GET", "/test-path", nil)
rr := httptest.NewRecorder()
// This should not panic
wrapped.ServeHTTP(rr, req)
assert.Equal(t, http.StatusOK, rr.Code)
}
func TestServer_ServesStaticFiles(t *testing.T) {
// Create a temp directory with a test file
tempDir := t.TempDir()
// Create a test file with a simple name
testFile := filepath.Join(tempDir, "hello.txt")
err := os.WriteFile(testFile, []byte("Hello, World!"), 0644)
require.NoError(t, err)
s := New(tempDir, "0")
// Use http.StripPrefix with the file server to avoid redirect issues
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
// Create test server
ts := httptest.NewServer(fs)
defer ts.Close()
// Test serving the text file via HTTP
resp, err := http.Get(ts.URL + "/hello.txt")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, "Hello, World!", string(body))
// Verify the server object is set up correctly
assert.Equal(t, tempDir, s.directory)
}
func TestServer_404ForNonExistentFile(t *testing.T) {
tempDir := t.TempDir()
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
resp, err := http.Get(ts.URL + "/nonexistent.txt")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestServer_ServesNestedDirectories(t *testing.T) {
tempDir := t.TempDir()
// Create nested directory structure
nestedDir := filepath.Join(tempDir, "data", "repos")
err := os.MkdirAll(nestedDir, 0755)
require.NoError(t, err)
// Create a file in nested directory
testFile := filepath.Join(nestedDir, "metrics.json")
err = os.WriteFile(testFile, []byte(`{"count": 42}`), 0644)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
resp, err := http.Get(ts.URL + "/data/repos/metrics.json")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "42")
}
func TestServer_MiddlewareCombination(t *testing.T) {
t.Parallel()
s := New(".", "8080")
innerHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("response"))
})
// Combine middlewares like in the actual server
combined := s.loggingMiddleware(s.cacheMiddleware(innerHandler))
req := httptest.NewRequest("GET", "/any-path", nil)
rr := httptest.NewRecorder()
combined.ServeHTTP(rr, req)
// Check response
assert.Equal(t, http.StatusOK, rr.Code)
body, _ := io.ReadAll(rr.Body)
assert.Equal(t, "response", string(body))
// Check headers were set by cache middleware
assert.NotEmpty(t, rr.Header().Get("Cache-Control"))
}
func TestServer_ServesIndexHtml(t *testing.T) {
tempDir := t.TempDir()
// Create an index.html
indexFile := filepath.Join(tempDir, "index.html")
err := os.WriteFile(indexFile, []byte("<html><body>Test Page</body></html>"), 0644)
require.NoError(t, err)
absPath, _ := filepath.Abs(tempDir)
fs := http.FileServer(http.Dir(absPath))
ts := httptest.NewServer(fs)
defer ts.Close()
// Test serving index.html via root path
resp, err := http.Get(ts.URL + "/")
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
body, _ := io.ReadAll(resp.Body)
assert.Contains(t, string(body), "Test Page")
}
+8
View File
@@ -0,0 +1,8 @@
package version
// Version information set at build time
var (
Version = "dev"
Commit = "unknown"
BuildDate = "unknown"
)
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Git Velocity</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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">
</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>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2303
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "git-velocity-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"chart.js": "^4.4.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"vite": "^5.0.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+49
View File
@@ -0,0 +1,49 @@
<script setup>
import { ref, onMounted, provide } from 'vue'
import Navbar from './components/Navbar.vue'
import Footer from './components/Footer.vue'
const globalData = ref(null)
const loading = ref(true)
const error = ref(null)
provide('globalData', globalData)
onMounted(async () => {
try {
const response = await fetch('./data/global.json')
if (!response.ok) throw new Error('Failed to load data')
globalData.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
</script>
<template>
<div class="min-h-screen flex flex-col">
<Navbar />
<main class="flex-1">
<div v-if="loading" class="flex items-center justify-center min-h-[60vh]">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
<p class="text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div>
</div>
<div v-else-if="error" class="flex items-center justify-center min-h-[60vh]">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-4xl text-red-500 mb-4"></i>
<p class="text-gray-600 dark:text-gray-400">{{ error }}</p>
</div>
</div>
<router-view v-else />
</main>
<Footer />
</div>
</template>
+179
View File
@@ -0,0 +1,179 @@
<script setup>
defineProps({
achievementId: { type: String, required: true },
size: { type: String, default: 'md' }, // sm, md, lg
showLabel: { type: Boolean, default: false }
})
// Tier colors based on threshold (1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000+)
const tierGradients = {
1: 'from-stone-400 to-stone-500', // Bronze - tier 1
2: 'from-green-400 to-emerald-500', // Green - tier 10
3: 'from-blue-400 to-indigo-500', // Blue - tier 25
4: 'from-purple-400 to-violet-500', // Purple - tier 50
5: 'from-yellow-400 to-amber-500', // Gold - tier 100
6: 'from-orange-400 to-red-500', // Orange - tier 250
7: 'from-red-500 to-rose-600', // Red - tier 500
8: 'from-pink-500 to-fuchsia-600', // Pink - tier 1000
9: 'from-cyan-400 to-teal-500', // Cyan - tier 5000
10: 'from-emerald-400 to-cyan-500', // Emerald - tier 10000
11: 'from-violet-500 to-purple-600', // Legendary - tier 25000+
}
// Get tier from threshold number
const getTierFromThreshold = (threshold) => {
const tiers = [1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000]
for (let i = tiers.length - 1; i >= 0; i--) {
if (threshold >= tiers[i]) return i + 1
}
return 1
}
// Extract threshold from achievement ID (e.g., "commit-100" -> 100)
const extractThreshold = (id) => {
const match = id.match(/(\d+)$/)
if (match) return parseInt(match[1], 10)
// Special cases for non-numeric achievements
if (id === 'first-commit' || id === 'pr-opener' || id === 'reviewer') return 1
return 50 // Default for special achievements
}
// Achievement definitions matching the Go backend
const achievements = {
// Commit achievements - Journey from apprentice to legend
'first-commit': { name: 'Hello World', description: 'Made your first commit', icon: 'fa-baby' },
'commit-10': { name: 'Seedling', description: 'Made 10 commits', icon: 'fa-seedling' },
'commit-25': { name: 'Momentum', description: 'Made 25 commits', icon: 'fa-wind' },
'commit-50': { name: 'Trailblazer', description: 'Made 50 commits', icon: 'fa-hiking' },
'commit-100': { name: 'Centurion', description: 'Made 100 commits', icon: 'fa-shield-halved' },
'commit-250': { name: 'Relentless', description: 'Made 250 commits', icon: 'fa-bolt-lightning' },
'commit-500': { name: 'Unstoppable', description: 'Made 500 commits', icon: 'fa-meteor' },
'commit-1000': { name: 'Grandmaster', description: 'Made 1000 commits', icon: 'fa-chess-king' },
'commit-5000': { name: 'Titan', description: 'Made 5000 commits', icon: 'fa-mountain-sun' },
'commit-10000': { name: 'Immortal', description: 'Made 10000 commits', icon: 'fa-dragon' },
'commit-25000': { name: 'Ascended', description: 'Made 25000 commits', icon: 'fa-infinity' },
// PR achievements - The art of collaboration
'pr-opener': { name: 'First Blood', description: 'Opened your first pull request', icon: 'fa-flag-checkered' },
'pr-10': { name: 'Collaborator', description: 'Opened 10 pull requests', icon: 'fa-handshake' },
'pr-25': { name: 'Integrator', description: 'Opened 25 pull requests', icon: 'fa-code-branch' },
'pr-50': { name: 'Architect', description: 'Opened 50 pull requests', icon: 'fa-building' },
'pr-100': { name: 'Vanguard', description: 'Opened 100 pull requests', icon: 'fa-rocket' },
// Review achievements - The guardian path
'reviewer': { name: 'Watchful Eye', description: 'Reviewed your first pull request', icon: 'fa-eye' },
'reviewer-10': { name: 'Sentinel', description: 'Reviewed 10 pull requests', icon: 'fa-shield' },
'reviewer-25': { name: 'Gatekeeper', description: 'Reviewed 25 pull requests', icon: 'fa-dungeon' },
'reviewer-50': { name: 'Oracle', description: 'Reviewed 50 pull requests', icon: 'fa-hat-wizard' },
'reviewer-100': { name: 'Sage', description: 'Reviewed 100 pull requests', icon: 'fa-book-skull' },
// Speed achievements - Time is of the essence
'speed-demon': { name: 'Lightning Rod', description: 'Average review response under 1 hour', icon: 'fa-bolt' },
'quick-responder': { name: 'Flash', description: 'Average review response under 4 hours', icon: 'fa-gauge-high' },
// Comment achievements
'commentator': { name: 'Wordsmith', description: 'Left 50 PR review comments', icon: 'fa-feather-pointed' },
// Lines of code achievements - Volume mastery
'lines-1000': { name: 'Scribe', description: 'Added 1000 lines of code', icon: 'fa-scroll' },
'lines-10000': { name: 'Novelist', description: 'Added 10000 lines of code', icon: 'fa-book' },
'lines-100000': { name: 'Encyclopedia', description: 'Added 100000 lines of code', icon: 'fa-landmark' },
// Deletion achievements - The minimalist way
'cleaner': { name: 'Pruner', description: 'Deleted 1000 lines of code', icon: 'fa-scissors' },
'refactorer': { name: 'Surgeon', description: 'Deleted 10000 lines of code', icon: 'fa-syringe' },
'annihilator': { name: 'Annihilator', description: 'Deleted 100000 lines of code', icon: 'fa-explosion' },
// Multi-repo achievements - The wanderer
'multi-repo': { name: 'Nomad', description: 'Contributed to 5 repositories', icon: 'fa-compass' },
'multi-repo-10': { name: 'Explorer', description: 'Contributed to 10 repositories', icon: 'fa-map' },
// Team collaboration - Social butterfly
'team-player': { name: 'Ambassador', description: 'Reviewed PRs from 10 different contributors', icon: 'fa-users' },
'team-player-25': { name: 'Diplomat', description: 'Reviewed PRs from 25 different contributors', icon: 'fa-globe' },
// PR size achievements - Go big or go home
'big-pr': { name: 'Heavyweight', description: 'Merged a PR with 1000+ lines', icon: 'fa-dumbbell' },
'mega-pr': { name: 'Colossus', description: 'Merged a PR with 5000+ lines', icon: 'fa-monument' },
// Small PR achievements - Precision strikes
'small-pr-10': { name: 'Minimalist', description: 'Merged 10 PRs under 100 lines', icon: 'fa-compress' },
'small-pr-50': { name: 'Atomic', description: 'Merged 50 PRs under 100 lines', icon: 'fa-atom' },
// Perfect PR achievements - Flawless execution
'perfect-pr-5': { name: 'Sharpshooter', description: '5 PRs merged without changes requested', icon: 'fa-bullseye' },
'perfect-pr-25': { name: 'Perfectionist', description: '25 PRs merged without changes requested', icon: 'fa-gem' },
'perfect-pr-100': { name: 'Immaculate', description: '100 PRs merged without changes requested', icon: 'fa-crown' },
// Streak achievements - Consistency is key
'streak-7': { name: 'Hot Streak', description: '7 day contribution streak', icon: 'fa-fire' },
'streak-30': { name: 'Ironclad', description: '30 day contribution streak', icon: 'fa-link' },
'streak-90': { name: 'Unbreakable', description: '90 day contribution streak', icon: 'fa-diamond' },
// Time-based achievements - When you code matters
'early-bird': { name: 'Dawn Patrol', description: '50 commits before 9am', icon: 'fa-sun' },
'night-owl': { name: 'Nighthawk', description: '50 commits after 9pm', icon: 'fa-moon' },
'nosferatu': { name: 'Vampire', description: '25 commits between midnight and 4am', icon: 'fa-ghost' },
'weekend-warrior': { name: 'No Days Off', description: '25 weekend commits', icon: 'fa-calendar-xmark' },
// Activity achievements - Showing up matters
'active-30': { name: 'Reliable', description: 'Active on 30 different days', icon: 'fa-calendar-check' },
'active-100': { name: 'Stalwart', description: 'Active on 100 different days', icon: 'fa-tower-observation' },
'active-365': { name: 'Eternal', description: 'Active on 365 different days', icon: 'fa-sun-plant-wilt' }
}
const getAchievement = (id) => {
const base = achievements[id] || { name: id, description: '', icon: 'fa-medal' }
const threshold = extractThreshold(id)
const tier = getTierFromThreshold(threshold)
const gradient = tierGradients[tier] || 'from-gray-400 to-gray-500'
return { ...base, gradient, tier, threshold }
}
const sizeClasses = {
sm: { wrapper: 'w-9 h-9', icon: 'text-sm', radius: 'rounded-lg' },
md: { wrapper: 'w-11 h-11', icon: 'text-base', radius: 'rounded-xl' },
lg: { wrapper: 'w-14 h-14', icon: 'text-lg', radius: 'rounded-xl' }
}
</script>
<template>
<div class="inline-flex flex-col items-center gap-2">
<!-- Badge -->
<div
class="relative group/badge"
:title="getAchievement(achievementId).name"
>
<!-- Badge square with rounded corners -->
<div
class="flex items-center justify-center bg-gradient-to-br shadow-lg hover:scale-105 hover:shadow-xl transition-all duration-200 cursor-pointer"
:class="[
sizeClasses[size].wrapper,
sizeClasses[size].radius,
getAchievement(achievementId).gradient
]"
>
<i
class="fas text-white drop-shadow-sm"
:class="[getAchievement(achievementId).icon, sizeClasses[size].icon]"
></i>
</div>
<!-- Tooltip -->
<div class="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-xl opacity-0 group-hover/badge:opacity-100 transition-all duration-200 pointer-events-none whitespace-nowrap z-50 shadow-xl border border-white/10">
<div class="font-bold text-sm">{{ getAchievement(achievementId).name }}</div>
<div class="text-gray-300 text-[11px] mt-0.5">{{ getAchievement(achievementId).description }}</div>
<div class="absolute top-full left-1/2 -translate-x-1/2 border-[6px] border-transparent border-t-gray-900 dark:border-t-gray-800"></div>
</div>
</div>
<!-- Label (optional) - no truncation -->
<span
v-if="showLabel"
class="text-[11px] font-medium text-gray-600 dark:text-gray-400 text-center leading-tight"
style="max-width: 72px; word-wrap: break-word;"
>
{{ getAchievement(achievementId).name }}
</span>
</div>
</template>
+335
View File
@@ -0,0 +1,335 @@
<script setup>
import { computed } from 'vue'
import { formatNumber } from '../composables/formatters'
const props = defineProps({
contributor: { type: Object, required: true },
showEarned: { type: Boolean, default: false },
maxDisplay: { type: Number, default: 6 }
})
// Achievement tier thresholds
const tiers = [1, 10, 25, 50, 100, 250, 500, 1000, 5000, 10000, 25000]
// Tier gradient colors
const tierGradients = {
1: 'from-stone-400 to-stone-500',
2: 'from-green-400 to-emerald-500',
3: 'from-blue-400 to-indigo-500',
4: 'from-purple-400 to-violet-500',
5: 'from-yellow-400 to-amber-500',
6: 'from-orange-400 to-red-500',
7: 'from-red-500 to-rose-600',
8: 'from-pink-500 to-fuchsia-600',
9: 'from-cyan-400 to-teal-500',
10: 'from-emerald-400 to-cyan-500',
11: 'from-violet-500 to-purple-600',
}
// Progress bar colors based on tier
const tierProgressColors = {
1: 'bg-stone-500',
2: 'bg-green-500',
3: 'bg-blue-500',
4: 'bg-purple-500',
5: 'bg-yellow-500',
6: 'bg-orange-500',
7: 'bg-red-500',
8: 'bg-pink-500',
9: 'bg-cyan-500',
10: 'bg-emerald-500',
11: 'bg-violet-500',
}
// Achievement definitions with progress tracking
const achievementTypes = [
{
category: 'Commits',
icon: 'fa-code-commit',
iconColor: 'text-green-500',
getValue: (c) => c.commit_count || 0,
achievements: [
{ id: 'first-commit', threshold: 1, name: 'First Steps' },
{ id: 'commit-10', threshold: 10, name: 'Getting Started' },
{ id: 'commit-25', threshold: 25, name: 'Warming Up' },
{ id: 'commit-50', threshold: 50, name: 'On A Roll' },
{ id: 'commit-100', threshold: 100, name: 'Committed' },
{ id: 'commit-250', threshold: 250, name: 'Dedicated' },
{ id: 'commit-500', threshold: 500, name: 'Code Machine' },
{ id: 'commit-1000', threshold: 1000, name: 'Code Warrior' },
{ id: 'commit-5000', threshold: 5000, name: 'Legendary' },
{ id: 'commit-10000', threshold: 10000, name: 'Mythical' },
{ id: 'commit-25000', threshold: 25000, name: 'Transcendent' },
]
},
{
category: 'Pull Requests',
icon: 'fa-code-pull-request',
iconColor: 'text-blue-500',
getValue: (c) => c.prs_opened || 0,
achievements: [
{ id: 'pr-opener', threshold: 1, name: 'PR Pioneer' },
{ id: 'pr-10', threshold: 10, name: 'Pull Request Pro' },
{ id: 'pr-25', threshold: 25, name: 'PR Regular' },
{ id: 'pr-50', threshold: 50, name: 'Merge Master' },
{ id: 'pr-100', threshold: 100, name: 'PR Champion' },
]
},
{
category: 'Reviews',
icon: 'fa-eye',
iconColor: 'text-purple-500',
getValue: (c) => c.reviews_given || 0,
achievements: [
{ id: 'reviewer', threshold: 1, name: 'Code Reviewer' },
{ id: 'reviewer-10', threshold: 10, name: 'Review Starter' },
{ id: 'reviewer-25', threshold: 25, name: 'Review Regular' },
{ id: 'reviewer-50', threshold: 50, name: 'Review Expert' },
{ id: 'reviewer-100', threshold: 100, name: 'Review Guru' },
]
},
{
category: 'Lines Added',
icon: 'fa-plus',
iconColor: 'text-emerald-500',
getValue: (c) => c.lines_added || 0,
achievements: [
{ id: 'lines-1000', threshold: 1000, name: 'Thousand Lines' },
{ id: 'lines-10000', threshold: 10000, name: 'Ten Thousand' },
]
},
{
category: 'Lines Deleted',
icon: 'fa-minus',
iconColor: 'text-red-500',
getValue: (c) => c.lines_deleted || 0,
achievements: [
{ id: 'cleaner', threshold: 1000, name: 'Code Cleaner' },
{ id: 'refactorer', threshold: 10000, name: 'Refactoring Champion' },
]
},
{
category: 'Small PRs',
icon: 'fa-compress',
iconColor: 'text-cyan-500',
getValue: (c) => c.small_pr_count || 0,
achievements: [
{ id: 'small-pr-10', threshold: 10, name: 'Small PR Advocate' },
{ id: 'small-pr-50', threshold: 50, name: 'Atomic Commits Hero' },
]
},
{
category: 'Perfect PRs',
icon: 'fa-gem',
iconColor: 'text-pink-500',
getValue: (c) => c.perfect_prs || 0,
achievements: [
{ id: 'perfect-pr-5', threshold: 5, name: 'Clean Code' },
{ id: 'perfect-pr-25', threshold: 25, name: 'Flawless' },
]
},
{
category: 'Active Days',
icon: 'fa-calendar-check',
iconColor: 'text-orange-500',
getValue: (c) => c.active_days || 0,
achievements: [
{ id: 'active-30', threshold: 30, name: 'Consistent Contributor' },
{ id: 'active-100', threshold: 100, name: 'Dedicated Developer' },
]
},
{
category: 'Streak',
icon: 'fa-fire',
iconColor: 'text-amber-500',
getValue: (c) => c.longest_streak || 0,
achievements: [
{ id: 'streak-7', threshold: 7, name: 'Week Warrior' },
{ id: 'streak-30', threshold: 30, name: 'Month Master' },
]
},
]
// Get tier number from threshold
const getTier = (threshold) => {
for (let i = tiers.length - 1; i >= 0; i--) {
if (threshold >= tiers[i]) return i + 1
}
return 1
}
// Find all tiers for a category to show progression
const getTiersForCategory = (achievements) => {
return achievements.map(a => ({
threshold: a.threshold,
name: a.name,
tier: getTier(a.threshold)
}))
}
// Calculate progress for each achievement type
const progressItems = computed(() => {
const earnedSet = new Set(props.contributor.achievements || [])
const results = []
for (const type of achievementTypes) {
const currentValue = type.getValue(props.contributor)
// Find the FIRST achievement where currentValue < threshold (true next target)
// Also track all earned achievements
let targetAchievement = null
let lastEarned = null
const allTiers = getTiersForCategory(type.achievements)
for (const ach of type.achievements) {
if (currentValue >= ach.threshold) {
// User has reached this threshold (should be earned)
lastEarned = ach
} else if (!targetAchievement) {
// First achievement they haven't reached yet
targetAchievement = ach
}
}
// Skip if no target (all thresholds exceeded)
if (!targetAchievement) continue
// Calculate progress from last threshold to next
const previousThreshold = lastEarned ? lastEarned.threshold : 0
const progressRange = targetAchievement.threshold - previousThreshold
const currentProgress = currentValue - previousThreshold
const progress = Math.min(100, Math.max(0, Math.round((currentProgress / progressRange) * 100)))
const tier = getTier(targetAchievement.threshold)
// Find current tier position and total tiers
const currentTierIndex = allTiers.findIndex(t => t.threshold === targetAchievement.threshold)
const totalTiers = allTiers.length
results.push({
category: type.category,
icon: type.icon,
iconColor: type.iconColor,
currentValue,
target: targetAchievement.threshold,
name: targetAchievement.name,
id: targetAchievement.id,
progress,
tier,
tierIndex: currentTierIndex + 1,
totalTiers,
allTiers,
gradient: tierGradients[tier],
progressColor: tierProgressColors[tier],
isClose: progress >= 75,
remaining: targetAchievement.threshold - currentValue,
isEarned: earnedSet.has(targetAchievement.id),
})
}
// Sort by progress (closest to completion first)
results.sort((a, b) => b.progress - a.progress)
return results.slice(0, props.maxDisplay)
})
// Get count of remaining achievements (all unearned across all types)
const remainingCount = computed(() => {
const earnedSet = new Set(props.contributor.achievements || [])
let totalUnearned = 0
for (const type of achievementTypes) {
const currentValue = type.getValue(props.contributor)
for (const ach of type.achievements) {
// Count achievements where user hasn't reached the threshold
if (currentValue < ach.threshold) {
totalUnearned++
}
}
}
return Math.max(0, totalUnearned - props.maxDisplay)
})
</script>
<template>
<div class="space-y-3">
<div
v-for="item in progressItems"
:key="item.id"
class="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center space-x-3">
<div
class="w-10 h-10 rounded-lg bg-gradient-to-br flex items-center justify-center shadow-md"
:class="item.gradient"
>
<i class="fas text-white text-sm" :class="item.icon"></i>
</div>
<div>
<div class="text-sm font-semibold text-gray-800 dark:text-white">
{{ item.name }}
</div>
<div class="flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400">
<span>{{ item.category }}</span>
<span class="text-gray-300 dark:text-gray-600"></span>
<span class="font-medium">Tier {{ item.tierIndex }}/{{ item.totalTiers }}</span>
</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-bold" :class="item.isClose ? 'text-green-500' : 'text-gray-700 dark:text-gray-200'">
{{ formatNumber(item.currentValue) }}
<span class="text-gray-400 dark:text-gray-500 font-normal">/</span>
<span class="text-gray-500 dark:text-gray-400 font-medium">{{ formatNumber(item.target) }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ item.remaining > 0 ? `${formatNumber(item.remaining)} to go` : 'Ready to claim!' }}
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="h-2.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 ease-out"
:class="item.progressColor"
:style="{ width: `${item.progress}%` }"
></div>
</div>
<!-- Progress percentage and tier markers -->
<div class="flex items-center justify-between mt-1.5">
<div class="flex items-center space-x-1">
<span
v-for="(t, idx) in item.allTiers.slice(0, 5)"
:key="t.threshold"
class="w-1.5 h-1.5 rounded-full"
:class="idx < item.tierIndex ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'"
:title="`Tier ${idx + 1}: ${t.name} (${formatNumber(t.threshold)})`"
></span>
<span v-if="item.totalTiers > 5" class="text-[10px] text-gray-400">+{{ item.totalTiers - 5 }}</span>
</div>
<span
class="text-xs font-semibold"
:class="item.isClose ? 'text-green-500' : 'text-gray-400 dark:text-gray-500'"
>
{{ item.progress }}%
</span>
</div>
</div>
<!-- Show more indicator -->
<div v-if="remainingCount > 0" class="text-center text-xs text-gray-500 dark:text-gray-400 pt-2">
+{{ remainingCount }} more achievements to unlock
</div>
<!-- Empty state -->
<div v-if="!progressItems.length" class="text-center py-8 text-gray-500 dark:text-gray-400">
<div class="w-16 h-16 mx-auto mb-3 rounded-2xl bg-gradient-to-br from-yellow-400 to-amber-500 flex items-center justify-center shadow-lg">
<i class="fas fa-trophy text-2xl text-white"></i>
</div>
<p class="font-medium text-gray-700 dark:text-gray-300">All achievements unlocked!</p>
<p class="text-sm mt-1">You're a legend!</p>
</div>
</div>
</template>
+37
View File
@@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
name: { type: String, required: true },
size: { type: String, default: 'md' }
})
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-14 h-14 text-xl',
xl: 'w-16 h-16 text-2xl',
'2xl': 'w-32 h-32 text-4xl'
}
const initial = computed(() => props.name.charAt(0).toUpperCase())
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
</script>
<template>
<img
v-if="src"
:src="src"
:alt="name"
:class="classes"
class="rounded-full"
/>
<div
v-else
:class="classes"
class="rounded-full bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center text-white font-bold"
>
{{ initial }}
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { RouterLink } from 'vue-router'
defineProps({
items: {
type: Array,
required: true
// Each item: { label: string, to?: string }
}
})
</script>
<template>
<div class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-6">
<template v-for="(item, index) in items" :key="index">
<RouterLink
v-if="item.to"
:to="item.to"
class="hover:text-primary-500"
>
{{ item.label }}
</RouterLink>
<span
v-else
:class="index === items.length - 1 ? 'text-gray-800 dark:text-white' : ''"
>
{{ item.label }}
</span>
<i
v-if="index < items.length - 1"
class="fas fa-chevron-right text-xs"
></i>
</template>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup>
import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue'
import RankBadge from './RankBadge.vue'
import AchievementBadge from './AchievementBadge.vue'
import { formatNumber } from '../composables/formatters'
defineProps({
contributor: { type: Object, required: true },
rank: { type: Number, default: 0 },
showRank: { type: Boolean, default: true },
featured: { type: Boolean, default: false }
})
</script>
<template>
<RouterLink
:to="{ name: 'contributor', params: { login: contributor.login } }"
:class="[
'card animate-fade-in-up block cursor-pointer hover:shadow-lg transition-shadow',
featured && rank === 1 ? 'ring-2 ring-yellow-400' : ''
]"
>
<div class="flex items-center space-x-4">
<div class="relative">
<Avatar
:src="contributor.avatar_url"
:name="contributor.login"
:size="featured ? 'xl' : 'lg'"
/>
<RankBadge
v-if="showRank && rank > 0"
:rank="rank"
size="sm"
class="absolute -top-1 -right-1"
/>
</div>
<div class="flex-1">
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
{{ contributor.name || contributor.login }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span
class="hover:text-primary-500 transition-colors"
@click.stop.prevent="window.open(`https://github.com/${contributor.login}`, '_blank')"
>
@{{ contributor.login }}
<i class="fas fa-external-link-alt text-xs ml-0.5 opacity-50"></i>
</span>
</p>
<p v-if="contributor.team" class="text-xs text-accent-500">{{ contributor.team }}</p>
</div>
<div class="text-right">
<div class="text-2xl font-bold gradient-text">
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">points</div>
</div>
</div>
<div v-if="contributor.achievements?.length" class="mt-4 flex flex-wrap gap-1.5">
<AchievementBadge
v-for="achievement in contributor.achievements.slice(0, 6)"
:key="achievement"
:achievement-id="achievement"
size="sm"
/>
<span
v-if="contributor.achievements.length > 6"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
>
+{{ contributor.achievements.length - 6 }}
</span>
</div>
</RouterLink>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup>
import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue'
import { formatNumber } from '../composables/formatters'
defineProps({
contributor: {
type: Object,
required: true
},
showGithubLink: {
type: Boolean,
default: false
},
columns: {
type: Array,
default: () => ['commits', 'prs', 'reviews', 'lines', 'score']
}
})
</script>
<template>
<RouterLink
:to="{ name: 'contributor', params: { login: contributor.login } }"
class="flex items-center space-x-3"
>
<Avatar
:src="contributor.avatar_url"
:name="contributor.login"
class="ring-2 ring-transparent group-hover:ring-primary-500 transition-all"
/>
<div>
<div class="font-medium text-gray-800 dark:text-white group-hover:text-primary-500 transition-colors">
{{ contributor.name || contributor.login }}
</div>
<div class="text-sm">
<a
v-if="showGithubLink"
:href="`https://github.com/${contributor.login}`"
target="_blank"
rel="noopener noreferrer"
class="text-gray-500 dark:text-gray-400 hover:text-primary-500 transition-colors"
@click.stop
>
@{{ contributor.login }}
<i class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
</a>
<span v-else class="text-gray-500 dark:text-gray-400">
@{{ contributor.login }}
</span>
</div>
</div>
</RouterLink>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup>
defineProps({
columns: {
type: Array,
required: true
// Each column: { key: string, label: string, align?: 'left'|'center'|'right', class?: string, headerClass?: string }
},
items: {
type: Array,
default: () => []
},
emptyIcon: {
type: String,
default: 'fas fa-inbox'
},
emptyMessage: {
type: String,
default: 'No data found'
},
rowClass: {
type: String,
default: 'hover:bg-gray-50 dark:hover:bg-gray-800/30 transition'
},
clickableRows: {
type: Boolean,
default: false
}
})
defineEmits(['row-click'])
const getAlignClass = (align) => {
switch (align) {
case 'center': return 'text-center'
case 'right': return 'text-right'
default: return 'text-left'
}
}
</script>
<template>
<div class="card overflow-hidden p-0">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th
v-for="col in columns"
:key="col.key"
:class="[
'px-6 py-4 text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider',
getAlignClass(col.align),
col.headerClass
]"
>
{{ col.label }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<tr
v-for="(item, index) in items"
:key="item.id || item.login || index"
:class="[rowClass, { 'cursor-pointer': clickableRows }]"
@click="clickableRows && $emit('row-click', item)"
>
<td
v-for="col in columns"
:key="col.key"
:class="['px-6 py-4', getAlignClass(col.align), col.class]"
>
<slot :name="col.key" :item="item" :index="index">
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
<!-- Empty State -->
<div v-if="!items.length" class="text-center py-12">
<i :class="emptyIcon" class="text-4xl text-gray-300 dark:text-gray-600 mb-4"></i>
<p class="text-gray-500 dark:text-gray-400">{{ emptyMessage }}</p>
</div>
</div>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup>
defineProps({
message: {
type: String,
default: 'An error occurred'
},
icon: {
type: String,
default: 'fas fa-exclamation-triangle'
}
})
</script>
<template>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="text-center">
<i :class="icon" class="text-4xl text-red-500 mb-4"></i>
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
<slot name="actions"></slot>
</div>
</div>
</template>
+35
View File
@@ -0,0 +1,35 @@
<script setup>
import { inject, computed } from 'vue'
const globalData = inject('globalData')
const generatedAt = computed(() => {
if (!globalData.value?.GeneratedAt) return ''
return new Date(globalData.value.GeneratedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
})
</script>
<template>
<footer class="py-8 px-4 mt-16 border-t border-gray-200 dark:border-gray-700">
<div class="container mx-auto text-center">
<p class="text-gray-500 dark:text-gray-400">
Generated by
<a
href="https://github.com/lukaszraczylo/git-velocity"
class="text-primary-500 hover:text-primary-600"
target="_blank"
rel="noopener noreferrer"
>
Git Velocity
</a>
</p>
<p v-if="generatedAt" class="text-sm text-gray-400 dark:text-gray-500 mt-2">
{{ generatedAt }}
</p>
</div>
</footer>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup>
defineProps({
url: {
type: String,
required: true
},
label: {
type: String,
default: ''
},
showIcon: {
type: Boolean,
default: true
}
})
</script>
<template>
<a
:href="url"
target="_blank"
rel="noopener noreferrer"
class="hover:text-primary-500 transition-colors"
@click.stop
>
<slot>{{ label }}</slot>
<i v-if="showIcon" class="fas fa-external-link-alt text-xs ml-1 opacity-50"></i>
</a>
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup>
defineProps({
message: {
type: String,
default: 'Loading...'
}
})
</script>
<template>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="text-center">
<i class="fas fa-spinner fa-spin text-4xl text-primary-500 mb-4"></i>
<p class="text-gray-600 dark:text-gray-400">{{ message }}</p>
</div>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
<script setup>
import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue'
import AchievementBadge from './AchievementBadge.vue'
import { formatNumber } from '../composables/formatters'
defineProps({
member: {
type: Object,
required: true
},
linkToProfile: {
type: Boolean,
default: true
}
})
</script>
<template>
<component
:is="linkToProfile ? RouterLink : 'div'"
:to="linkToProfile ? { name: 'contributor', params: { login: member.login } } : undefined"
class="card block"
:class="{ 'hover:shadow-lg transition cursor-pointer': linkToProfile }"
>
<div class="flex items-center space-x-4 mb-4">
<Avatar :src="member.avatar_url" :name="member.login" size="lg" />
<div>
<h3 class="font-semibold text-gray-800 dark:text-white">
{{ member.name || member.login }}
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">@{{ member.login }}</p>
</div>
</div>
<div class="grid grid-cols-3 gap-4 text-center mb-4">
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ formatNumber(member.commit_count) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ formatNumber(member.prs_opened) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ formatNumber(member.reviews_given) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Reviews</div>
</div>
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<span class="text-sm text-gray-500 dark:text-gray-400">Score</span>
<span class="text-xl font-bold gradient-text">
{{ formatNumber(member.score?.total || 0) }}
</span>
</div>
<div v-if="member.achievements?.length" class="mt-4 flex flex-wrap gap-2">
<AchievementBadge
v-for="achievement in member.achievements.slice(0, 4)"
:key="achievement"
:achievement-id="achievement"
size="sm"
/>
<span
v-if="member.achievements.length > 4"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 text-xs font-bold"
>
+{{ member.achievements.length - 4 }}
</span>
</div>
</component>
</template>
+90
View File
@@ -0,0 +1,90 @@
<script setup>
import { ref, inject, computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import ThemeToggle from './ThemeToggle.vue'
const route = useRoute()
const globalData = inject('globalData')
const mobileMenuOpen = ref(false)
const repositories = computed(() => globalData.value?.Repositories || [])
</script>
<template>
<nav class="sticky top-0 z-50 glass shadow-modern">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<RouterLink to="/" class="flex items-center space-x-2">
<i class="fas fa-rocket text-2xl gradient-text"></i>
<span class="text-xl font-bold gradient-text">Git Velocity</span>
</RouterLink>
<!-- Desktop Navigation -->
<div class="hidden md:flex items-center space-x-6">
<RouterLink
to="/"
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
>
Dashboard
</RouterLink>
<RouterLink
to="/leaderboard"
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
>
Leaderboard
</RouterLink>
<RouterLink
v-for="repo in repositories"
:key="`${repo.Owner}/${repo.Name}`"
:to="`/repos/${repo.Owner}/${repo.Name}`"
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
>
{{ repo.Name }}
</RouterLink>
</div>
<!-- Actions -->
<div class="flex items-center space-x-4">
<ThemeToggle />
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
<i class="fas fa-bars text-gray-700 dark:text-gray-200"></i>
</button>
</div>
</div>
<!-- Mobile Menu -->
<div v-if="mobileMenuOpen" class="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex flex-col space-y-3">
<RouterLink
to="/"
@click="mobileMenuOpen = false"
:class="route.path === '/' ? 'nav-link-active' : 'nav-link'"
>
Dashboard
</RouterLink>
<RouterLink
to="/leaderboard"
@click="mobileMenuOpen = false"
:class="route.path === '/leaderboard' ? 'nav-link-active' : 'nav-link'"
>
Leaderboard
</RouterLink>
<RouterLink
v-for="repo in repositories"
:key="`${repo.Owner}/${repo.Name}`"
:to="`/repos/${repo.Owner}/${repo.Name}`"
@click="mobileMenuOpen = false"
:class="route.path.includes(`/repos/${repo.Owner}/${repo.Name}`) ? 'nav-link-active' : 'nav-link'"
>
{{ repo.Name }}
</RouterLink>
</div>
</div>
</div>
</nav>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup>
import Breadcrumb from './Breadcrumb.vue'
defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
icon: {
type: String,
default: ''
},
iconColor: {
type: String,
default: 'text-primary-500'
},
breadcrumbs: {
type: Array,
default: () => []
},
centered: {
type: Boolean,
default: false
}
})
</script>
<template>
<header class="py-12 px-4">
<div class="container mx-auto" :class="{ 'text-center': centered }">
<Breadcrumb v-if="breadcrumbs.length" :items="breadcrumbs" />
<div class="flex items-center" :class="centered ? 'justify-center' : ''">
<slot name="prefix"></slot>
<h1 class="text-4xl font-bold mb-4">
<i v-if="icon" :class="[icon, iconColor]" class="mr-3"></i>
<span class="gradient-text">{{ title }}</span>
</h1>
</div>
<p v-if="subtitle || $slots.subtitle" class="text-gray-600 dark:text-gray-300">
<slot name="subtitle">{{ subtitle }}</slot>
</p>
<slot name="extra"></slot>
</div>
</header>
</template>
+31
View File
@@ -0,0 +1,31 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
rank: { type: Number, required: true },
size: { type: String, default: 'md' }
})
const sizeClasses = {
sm: 'w-6 h-6 text-xs',
md: 'w-8 h-8 text-sm'
}
const rankClass = computed(() => {
if (props.rank === 1) return 'rank-1'
if (props.rank === 2) return 'rank-2'
if (props.rank === 3) return 'rank-3'
return 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
})
const classes = computed(() => sizeClasses[props.size] || sizeClasses.md)
</script>
<template>
<span
:class="[classes, rankClass, { 'text-white': rank <= 3 }]"
class="inline-flex items-center justify-center rounded-full font-bold"
>
{{ rank }}
</span>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup>
import { RouterLink } from 'vue-router'
import { formatNumber } from '../composables/formatters'
defineProps({
repo: {
type: Object,
required: true
}
})
</script>
<template>
<RouterLink
:to="`/repos/${repo.owner}/${repo.name}`"
class="card hover:shadow-lg transition group"
>
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
{{ repo.name }}
</h3>
<i class="fas fa-arrow-right text-gray-400 group-hover:text-primary-500 transition"></i>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{ repo.owner }}/{{ repo.name }}</p>
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ formatNumber(repo.total_commits) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Commits</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ formatNumber(repo.total_prs) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">PRs</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ repo.active_contributors }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Contributors</div>
</div>
</div>
</RouterLink>
</template>
+23
View File
@@ -0,0 +1,23 @@
<script setup>
defineProps({
title: {
type: String,
required: true
},
icon: {
type: String,
default: ''
},
iconColor: {
type: String,
default: 'text-primary-500'
}
})
</script>
<template>
<h2 class="text-2xl font-bold text-gray-800 dark:text-white mb-6">
<i v-if="icon" :class="[icon, iconColor]" class="mr-2"></i>{{ title }}
<slot name="suffix"></slot>
</h2>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup>
import { formatNumber } from '../composables/formatters'
defineProps({
value: { type: [Number, String], required: true },
label: { type: String, required: true },
icon: { type: String, default: '' },
iconColor: { type: String, default: 'text-gray-500' },
valueClass: { type: String, default: 'gradient-text' },
delay: { type: String, default: '0s' }
})
</script>
<template>
<div class="card animate-fade-in-up" :style="{ animationDelay: delay }">
<div class="flex items-center justify-between">
<div>
<div class="text-3xl font-bold" :class="valueClass">
{{ typeof value === 'number' ? formatNumber(value) : value }}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ label }}</div>
</div>
<div v-if="icon" class="text-3xl opacity-50" :class="iconColor">
<i :class="icon"></i>
</div>
</div>
</div>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup>
import { RouterLink } from 'vue-router'
import Avatar from './Avatar.vue'
import { formatNumber, slugify } from '../composables/formatters'
defineProps({
team: {
type: Object,
required: true
}
})
</script>
<template>
<RouterLink
:to="`/teams/${slugify(team.name)}`"
class="card hover:shadow-lg transition group"
>
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-gray-800 dark:text-white group-hover:text-primary-500 transition">
{{ team.name }}
</h3>
<span
class="w-3 h-3 rounded-full"
:style="{ backgroundColor: team.color || '#8b5cf6' }"
></span>
</div>
<div class="flex items-center space-x-2 mb-4">
<template v-for="(member, i) in (team.members || []).slice(0, 5)" :key="member">
<Avatar :name="member" size="sm" />
</template>
<span
v-if="(team.members?.length || 0) > 5"
class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-300 text-xs font-bold"
>
+{{ team.members.length - 5 }}
</span>
</div>
<div class="grid grid-cols-2 gap-4 text-center">
<div>
<div class="text-lg font-semibold gradient-text">
{{ formatNumber(team.total_score) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Total Score</div>
</div>
<div>
<div class="text-lg font-semibold text-gray-800 dark:text-white">
{{ team.members?.length || 0 }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">Members</div>
</div>
</div>
</RouterLink>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
const isDark = ref(false)
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
isDark.value = savedTheme === 'dark' || (!savedTheme && prefersDark)
updateTheme()
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
updateTheme()
}
})
})
watch(isDark, () => {
updateTheme()
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
})
function updateTheme() {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
function toggle() {
isDark.value = !isDark.value
}
</script>
<template>
<button
@click="toggle"
class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition"
:aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
>
<i v-if="isDark" class="fas fa-moon text-purple-400"></i>
<i v-else class="fas fa-sun text-yellow-500"></i>
</button>
</template>
+168
View File
@@ -0,0 +1,168 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Chart, registerables } from 'chart.js'
Chart.register(...registerables)
const props = defineProps({
timeline: {
type: Object,
required: true
// Expected shape: { labels: string[], series: [{ name, color, data }] }
},
height: {
type: String,
default: '300px'
},
showScore: {
type: Boolean,
default: false
}
})
const chartRef = ref(null)
let chartInstance = null
const visibleSeries = computed(() => {
if (!props.timeline?.series) return []
// Filter out Score series unless showScore is true
return props.timeline.series.filter(s => props.showScore || s.name !== 'Score')
})
const chartData = computed(() => {
if (!props.timeline?.labels || !visibleSeries.value.length) {
return { labels: [], datasets: [] }
}
return {
labels: props.timeline.labels,
datasets: visibleSeries.value.map(series => ({
label: series.name,
data: series.data,
borderColor: series.color,
backgroundColor: series.color + '20', // Add transparency
fill: true,
tension: 0.4,
pointRadius: 3,
pointHoverRadius: 5
}))
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
plugins: {
legend: {
position: 'top',
labels: {
usePointStyle: true,
padding: 20,
font: {
size: 12
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14
},
bodyFont: {
size: 13
},
callbacks: {
label: (context) => {
return `${context.dataset.label}: ${context.parsed.y.toLocaleString()}`
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
size: 11
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)'
},
ticks: {
font: {
size: 11
},
callback: (value) => {
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'k'
}
return value
}
}
}
}
}
function createChart() {
if (!chartRef.value || !chartData.value.labels.length) return
if (chartInstance) {
chartInstance.destroy()
}
const ctx = chartRef.value.getContext('2d')
chartInstance = new Chart(ctx, {
type: 'line',
data: chartData.value,
options: chartOptions
})
}
function updateChart() {
if (chartInstance) {
chartInstance.data = chartData.value
chartInstance.update()
} else {
createChart()
}
}
onMounted(() => {
createChart()
})
watch(() => props.timeline, () => {
updateChart()
}, { deep: true })
watch(() => props.showScore, () => {
updateChart()
})
</script>
<template>
<div class="velocity-chart" :style="{ height }">
<canvas ref="chartRef"></canvas>
<div v-if="!timeline?.labels?.length" class="flex items-center justify-center h-full">
<p class="text-gray-400">No velocity data available</p>
</div>
</div>
</template>
<style scoped>
.velocity-chart {
position: relative;
width: 100%;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
/**
* Format a number with K/M suffixes for large values
*/
export function formatNumber(n) {
if (n === null || n === undefined) return '0'
if (n >= 1000000) {
return (n / 1000000).toFixed(1) + 'M'
}
if (n >= 1000) {
return (n / 1000).toFixed(1) + 'K'
}
return String(n)
}
/**
* Format hours as a human-readable duration
*/
export function formatDuration(hours) {
if (hours === null || hours === undefined) return '-'
if (hours < 1) {
return Math.round(hours * 60) + 'm'
}
if (hours < 24) {
return hours.toFixed(1) + 'h'
}
return (hours / 24).toFixed(1) + 'd'
}
/**
* Format a date string or Date object
*/
export function formatDate(dateInput) {
if (!dateInput) return ''
const date = new Date(dateInput)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
/**
* Format a number as a percentage
*/
export function formatPercent(value) {
if (value === null || value === undefined) return '0%'
return value.toFixed(1) + '%'
}
/**
* Convert a string to a URL-friendly slug
*/
export function slugify(str) {
if (!str) return ''
return str
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, '')
}
+31
View File
@@ -0,0 +1,31 @@
import { createApp } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import './style.css'
// Views
import Dashboard from './views/Dashboard.vue'
import Leaderboard from './views/Leaderboard.vue'
import Repository from './views/Repository.vue'
import Team from './views/Team.vue'
import Contributor from './views/Contributor.vue'
const routes = [
{ path: '/', name: 'dashboard', component: Dashboard },
{ path: '/leaderboard', name: 'leaderboard', component: Leaderboard },
{ path: '/repos/:owner/:name', name: 'repository', component: Repository },
{ path: '/teams/:slug', name: 'team', component: Team },
{ path: '/contributors/:login', name: 'contributor', component: Contributor },
]
const router = createRouter({
history: createWebHashHistory(),
routes,
scrollBehavior() {
return { top: 0 }
}
})
const app = createApp(App)
app.use(router)
app.mount('#app')
+78
View File
@@ -0,0 +1,78 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.glass {
@apply bg-white/70 dark:bg-gray-900/70 backdrop-blur-md border border-white/20 dark:border-white/10;
}
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent;
}
.shadow-modern {
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.15);
}
.dark .shadow-modern {
box-shadow: 0 10px 40px -10px rgba(0, 0, 0, 0.4);
}
.score-card {
@apply bg-gradient-to-r from-primary-400/10 to-accent-400/10 border border-primary-400/20;
}
.dark .score-card {
@apply from-primary-400/5 to-accent-400/5 border-primary-400/10;
}
.rank-1 {
@apply bg-gradient-to-r from-yellow-400 to-amber-500;
}
.rank-2 {
@apply bg-gradient-to-r from-slate-400 to-slate-500;
}
.rank-3 {
@apply bg-gradient-to-r from-amber-600 to-amber-700;
}
.achievement-badge {
@apply inline-flex items-center justify-center w-10 h-10 rounded-full bg-gradient-to-r from-primary-400 to-accent-400 text-white shadow-md;
}
.btn-primary {
@apply inline-flex items-center px-6 py-3 bg-gradient-to-r from-primary-500 to-accent-500 text-white font-medium rounded-lg hover:from-primary-600 hover:to-accent-600 transition shadow-modern;
}
.card {
@apply glass rounded-xl p-6 shadow-modern;
}
.nav-link {
@apply text-gray-700 dark:text-gray-200 hover:text-primary-500 dark:hover:text-primary-400 transition;
}
.nav-link-active {
@apply text-primary-500 font-medium;
}
}
@layer utilities {
.animate-fade-in-up {
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
}
+368
View File
@@ -0,0 +1,368 @@
<script setup>
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import LoadingState from '../components/LoadingState.vue'
import ErrorState from '../components/ErrorState.vue'
import StatCard from '../components/StatCard.vue'
import Avatar from '../components/Avatar.vue'
import AchievementBadge from '../components/AchievementBadge.vue'
import AchievementProgress from '../components/AchievementProgress.vue'
import SectionHeader from '../components/SectionHeader.vue'
import GithubLink from '../components/GithubLink.vue'
import { formatNumber, formatPercent, formatDuration } from '../composables/formatters'
const route = useRoute()
const globalData = inject('globalData')
const contributor = ref(null)
const loading = ref(true)
const error = ref(null)
const breadcrumbs = computed(() => [
{ label: 'Dashboard', to: '/' },
{ label: 'Contributors' },
{ label: contributor.value?.login || route.params.login }
])
async function loadContributor() {
loading.value = true
error.value = null
const login = route.params.login
try {
const response = await fetch(`./data/contributors/${login}.json`)
if (response.ok) {
const data = await response.json()
const leaderboard = globalData.value?.leaderboard || []
const leaderboardEntry = leaderboard.find(e => e.login === login)
if (leaderboardEntry) {
data.score = {
total: leaderboardEntry.score,
rank: leaderboardEntry.rank,
breakdown: data.score?.breakdown
}
data.achievements = leaderboardEntry.achievements
}
contributor.value = data
} else {
const leaderboard = globalData.value?.leaderboard || []
let found = leaderboard.find(e => e.login === login)
if (!found) {
const repos = globalData.value?.repositories || []
for (const repo of repos) {
const c = repo.contributors?.find(c => c.login === login)
if (c) {
found = c
break
}
}
}
if (found) {
contributor.value = found
} else {
error.value = 'Contributor not found'
}
}
} catch (e) {
error.value = `Failed to load contributor: ${e.message}`
}
loading.value = false
}
onMounted(loadContributor)
watch(() => route.params, loadContributor)
watch(globalData, loadContributor)
</script>
<template>
<div>
<LoadingState v-if="loading" message="Loading contributor..." />
<ErrorState v-else-if="error" :message="error" />
<template v-else-if="contributor">
<!-- Profile Header -->
<header class="py-12 px-4">
<div class="container mx-auto">
<PageHeader :breadcrumbs="breadcrumbs" :title="''" />
<div class="flex flex-col md:flex-row items-center md:items-start space-y-4 md:space-y-0 md:space-x-8">
<Avatar
:src="contributor.avatar_url"
:name="contributor.login"
size="2xl"
class="shadow-modern"
/>
<div class="text-center md:text-left">
<h1 class="text-4xl font-bold gradient-text">
{{ contributor.name || contributor.login }}
</h1>
<p class="text-xl text-gray-500 dark:text-gray-400 mt-1">
<GithubLink :url="`https://github.com/${contributor.login}`">
@{{ contributor.login }}
</GithubLink>
</p>
<div class="flex items-center justify-center md:justify-start space-x-4 mt-4">
<div class="score-card rounded-lg px-4 py-2">
<span class="text-sm text-gray-500 dark:text-gray-400">Score:</span>
<span class="text-2xl font-bold gradient-text ml-2">
{{ formatNumber(contributor.score?.total || contributor.score || 0) }}
</span>
</div>
<div v-if="contributor.score?.rank" class="text-sm text-gray-500 dark:text-gray-400">
Rank #{{ contributor.score.rank }}
<span v-if="contributor.score?.percentile_rank">
(Top {{ formatPercent(contributor.score.percentile_rank) }})
</span>
</div>
</div>
<div v-if="contributor.achievements?.length" class="mt-6 flex flex-wrap justify-center md:justify-start gap-3">
<AchievementBadge
v-for="achievement in contributor.achievements"
:key="achievement"
:achievement-id="achievement"
size="lg"
show-label
/>
</div>
</div>
</div>
</div>
</header>
<!-- Stats Grid -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
:value="contributor.commit_count || 0"
label="Commits"
icon="fas fa-code-commit"
icon-color="text-green-500"
/>
<StatCard
:value="contributor.prs_opened || 0"
label="PRs Opened"
icon="fas fa-code-pull-request"
icon-color="text-blue-500"
/>
<StatCard
:value="contributor.prs_merged || 0"
label="PRs Merged"
icon="fas fa-code-merge"
icon-color="text-purple-500"
/>
<StatCard
:value="contributor.reviews_given || 0"
label="Reviews Given"
icon="fas fa-eye"
icon-color="text-orange-500"
/>
</div>
</div>
</section>
<!-- Detailed Stats -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid md:grid-cols-2 gap-6">
<!-- Code Stats -->
<div class="card">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
<i class="fas fa-code text-green-500 mr-2"></i>Code Contributions
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Lines Added</span>
<span class="text-green-500 font-semibold">
+{{ formatNumber(contributor.lines_added || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Lines Deleted</span>
<span class="text-red-500 font-semibold">
-{{ formatNumber(contributor.lines_deleted || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Files Changed</span>
<span class="text-gray-800 dark:text-white font-semibold">
{{ formatNumber(contributor.files_changed || 0) }}
</span>
</div>
<div v-if="contributor.avg_pr_size" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Avg PR Size</span>
<span class="text-gray-800 dark:text-white font-semibold">
{{ formatNumber(Math.round(contributor.avg_pr_size)) }} lines
</span>
</div>
</div>
</div>
<!-- Review Stats -->
<div class="card">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
<i class="fas fa-comments text-purple-500 mr-2"></i>Review Activity
</h3>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Reviews Given</span>
<span class="text-gray-800 dark:text-white font-semibold">
{{ formatNumber(contributor.reviews_given || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Approvals</span>
<span class="text-green-500 font-semibold">
{{ formatNumber(contributor.approvals_given || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Changes Requested</span>
<span class="text-orange-500 font-semibold">
{{ formatNumber(contributor.changes_requested || 0) }}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Review Comments</span>
<span class="text-gray-800 dark:text-white font-semibold">
{{ formatNumber(contributor.review_comments || 0) }}
</span>
</div>
<div v-if="contributor.avg_review_time_hours" class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-300">Avg Review Time</span>
<span class="text-gray-800 dark:text-white font-semibold">
{{ formatDuration(contributor.avg_review_time_hours) }}
</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Score Breakdown -->
<section v-if="contributor.score?.breakdown" class="py-8 px-4">
<div class="container mx-auto">
<div class="card">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">
<i class="fas fa-chart-pie gradient-text mr-2"></i>Score Breakdown
</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<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">
{{ formatNumber(contributor.score.breakdown.commits || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Commits</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-blue-500">
{{ formatNumber(contributor.score.breakdown.prs || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">PRs</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-purple-500">
{{ formatNumber(contributor.score.breakdown.reviews || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Reviews</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-orange-500">
{{ formatNumber(contributor.score.breakdown.line_changes || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Line Changes</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-yellow-500">
{{ formatNumber(contributor.score.breakdown.response_bonus || 0) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">Response Bonus</div>
</div>
</div>
</div>
</div>
</section>
<!-- Achievement Progress Section -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid md:grid-cols-2 gap-6">
<!-- Earned Achievements -->
<div v-if="contributor.achievements?.length" class="card">
<div class="flex items-center justify-between mb-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white">
<i class="fas fa-award gradient-text mr-2"></i>Achievements Earned
</h3>
<span class="px-2.5 py-1 rounded-full bg-gradient-to-r from-yellow-400 to-amber-500 text-white text-sm font-bold shadow-md">
{{ contributor.achievements.length }}
</span>
</div>
<div class="grid grid-cols-4 sm:grid-cols-5 gap-3">
<div
v-for="achievement in contributor.achievements"
:key="achievement"
class="flex flex-col items-center p-2 rounded-xl bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<AchievementBadge
:achievement-id="achievement"
size="md"
show-label
/>
</div>
</div>
</div>
<!-- Progress to Next Achievements -->
<div class="card">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white mb-6">
<i class="fas fa-chart-line text-primary-500 mr-2"></i>Next Achievements
</h3>
<AchievementProgress
:contributor="contributor"
:max-display="6"
/>
</div>
</div>
</div>
</section>
<!-- Repositories Contributed -->
<section v-if="contributor.repositories_contributed?.length" class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader
:title="`Contributed to ${contributor.repositories_contributed.length} Repositories`"
icon="fas fa-folder-tree"
icon-color="text-blue-500"
/>
<div class="flex flex-wrap gap-2">
<RouterLink
v-for="repo in contributor.repositories_contributed"
:key="repo"
:to="`/repos/${repo}`"
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-300 transition-colors"
>
<i class="fas fa-code-branch text-gray-400 mr-2"></i>
{{ repo }}
</RouterLink>
</div>
</div>
</section>
</template>
</div>
</template>
+157
View File
@@ -0,0 +1,157 @@
<script setup>
import { inject, computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import StatCard from '../components/StatCard.vue'
import ContributorCard from '../components/ContributorCard.vue'
import RepoCard from '../components/RepoCard.vue'
import TeamCard from '../components/TeamCard.vue'
import SectionHeader from '../components/SectionHeader.vue'
import VelocityChart from '../components/VelocityChart.vue'
import { formatNumber, formatDate } from '../composables/formatters'
const globalData = inject('globalData')
const metrics = computed(() => globalData.value || {})
const leaderboard = computed(() => metrics.value.leaderboard?.slice(0, 3) || [])
const repositories = computed(() => metrics.value.repositories || [])
const teams = computed(() => metrics.value.teams || [])
const velocityTimeline = computed(() => metrics.value.velocity_timeline)
const showScoreInChart = ref(false)
</script>
<template>
<div>
<!-- Hero Section -->
<header class="py-16 px-4">
<div class="container mx-auto text-center animate-fade-in-up">
<h1 class="text-4xl md:text-6xl font-bold mb-4">
<span class="gradient-text">Git Velocity</span>
</h1>
<p class="text-xl text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Celebrate your team's achievements and contributions with beautiful insights.
</p>
<!-- Period and Generation Info -->
<div class="flex flex-col items-center space-y-2 mt-4 text-sm text-gray-500 dark:text-gray-400">
<p v-if="metrics.period?.start || metrics.period?.end">
<i class="fas fa-calendar-alt mr-1 text-primary-500"></i>
<span class="font-medium">Period:</span>
<span v-if="metrics.period.start">{{ formatDate(metrics.period.start) }}</span>
<span v-if="metrics.period.start && metrics.period.end"> &mdash; </span>
<span v-if="metrics.period.end">{{ formatDate(metrics.period.end) }}</span>
</p>
<p v-if="metrics.generated_at">
<i class="fas fa-clock mr-1"></i>
Generated on {{ formatDate(metrics.generated_at) }}
</p>
</div>
</div>
</header>
<!-- Velocity Timeline Chart -->
<section v-if="velocityTimeline" class="py-8 px-4">
<div class="container mx-auto">
<div class="card">
<div class="flex items-center justify-between mb-6">
<SectionHeader title="Velocity Timeline" icon="fas fa-chart-line" icon-color="text-primary-500" />
<label class="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
v-model="showScoreInChart"
class="rounded border-gray-300 text-primary-500 focus:ring-primary-500"
/>
<span>Show Score</span>
</label>
</div>
<VelocityChart :timeline="velocityTimeline" :show-score="showScoreInChart" height="320px" />
</div>
</div>
</section>
<!-- Stats Overview -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard
:value="metrics.total_contributors || 0"
label="Contributors"
delay="0s"
/>
<StatCard
:value="metrics.total_commits || 0"
label="Commits"
delay="0.1s"
/>
<StatCard
:value="metrics.total_prs || 0"
label="Pull Requests"
delay="0.2s"
/>
<StatCard
:value="metrics.total_reviews || 0"
label="Reviews"
delay="0.3s"
/>
<StatCard
:value="'+' + formatNumber(metrics.total_lines_added || 0)"
label="Lines Added"
value-class="text-green-500"
delay="0.4s"
/>
<StatCard
:value="'-' + formatNumber(metrics.total_lines_deleted || 0)"
label="Lines Deleted"
value-class="text-red-500"
delay="0.5s"
/>
</div>
</div>
</section>
<!-- Top Contributors -->
<section class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader title="Top Contributors" icon="fas fa-trophy" icon-color="text-yellow-500" />
<div class="grid md:grid-cols-3 gap-6">
<ContributorCard
v-for="(entry, index) in leaderboard"
:key="entry.login"
:contributor="entry"
:rank="index + 1"
featured
/>
</div>
<div class="mt-6 text-center">
<RouterLink to="/leaderboard" class="btn-primary">
View Full Leaderboard
<i class="fas fa-arrow-right ml-2"></i>
</RouterLink>
</div>
</div>
</section>
<!-- Repositories -->
<section class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader title="Repositories" icon="fas fa-code-branch" icon-color="text-accent-500" />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<RepoCard v-for="repo in repositories" :key="`${repo.owner}/${repo.name}`" :repo="repo" />
</div>
</div>
</section>
<!-- Teams -->
<section v-if="teams.length" class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader title="Teams" icon="fas fa-users" icon-color="text-blue-500" />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<TeamCard v-for="team in teams" :key="team.name" :team="team" />
</div>
</div>
</section>
</div>
</template>
+104
View File
@@ -0,0 +1,104 @@
<script setup>
import { inject, computed } from 'vue'
import PageHeader from '../components/PageHeader.vue'
import DataTable from '../components/DataTable.vue'
import ContributorRow from '../components/ContributorRow.vue'
import RankBadge from '../components/RankBadge.vue'
import AchievementBadge from '../components/AchievementBadge.vue'
import { formatNumber } from '../composables/formatters'
const globalData = inject('globalData')
const leaderboard = computed(() => globalData.value?.leaderboard || [])
const tableColumns = [
{ key: 'rank', label: 'Rank', align: 'left' },
{ key: 'contributor', label: 'Contributor', align: 'left' },
{ key: 'achievements', label: 'Achievements', align: 'left' },
{ key: 'team', label: 'Team', align: 'left', headerClass: 'hidden md:table-cell' },
{ key: 'category', label: 'Best At', align: 'left', headerClass: 'hidden sm:table-cell' },
{ key: 'score', label: 'Score', align: 'right' }
]
const categoryIcon = (category) => {
const icons = {
'Commits': 'fas fa-code-commit text-green-500',
'PRs': 'fas fa-code-pull-request text-blue-500',
'Reviews': 'fas fa-eye text-purple-500',
'Comments': 'fas fa-comment text-orange-500'
}
return icons[category] || ''
}
</script>
<template>
<div>
<PageHeader
title="Leaderboard"
subtitle="Top contributors ranked by their velocity score"
icon="fas fa-trophy"
icon-color="text-yellow-500"
centered
/>
<!-- Leaderboard Table -->
<section class="py-8 px-4">
<div class="container mx-auto max-w-5xl">
<DataTable
:columns="tableColumns"
:items="leaderboard"
empty-icon="fas fa-users"
empty-message="No contributors found"
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
>
<template #rank="{ item }">
<RankBadge :rank="item.rank" />
</template>
<template #contributor="{ item }">
<ContributorRow :contributor="item" show-github-link />
</template>
<template #achievements="{ item }">
<div class="flex flex-wrap gap-1.5 max-w-[180px]">
<AchievementBadge
v-for="achievement in (item.achievements || []).slice(0, 6)"
:key="achievement"
:achievement-id="achievement"
size="sm"
/>
<span v-if="!(item.achievements || []).length" class="text-gray-400 text-sm">-</span>
</div>
</template>
<template #team="{ item }">
<td class="hidden md:table-cell">
<span
v-if="item.team"
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300"
>
{{ item.team }}
</span>
<span v-else class="text-gray-400">-</span>
</td>
</template>
<template #category="{ item }">
<td class="hidden sm:table-cell">
<span v-if="item.top_category" class="text-sm text-gray-600 dark:text-gray-300">
<i :class="categoryIcon(item.top_category)" class="mr-1"></i>
{{ item.top_category }}
</span>
<span v-else class="text-gray-400">-</span>
</td>
</template>
<template #score="{ item }">
<span class="text-lg font-bold gradient-text">
{{ formatNumber(item.score) }}
</span>
</template>
</DataTable>
</div>
</section>
</div>
</template>
+143
View File
@@ -0,0 +1,143 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import LoadingState from '../components/LoadingState.vue'
import ErrorState from '../components/ErrorState.vue'
import StatCard from '../components/StatCard.vue'
import DataTable from '../components/DataTable.vue'
import ContributorRow from '../components/ContributorRow.vue'
import SectionHeader from '../components/SectionHeader.vue'
import GithubLink from '../components/GithubLink.vue'
import { formatNumber } from '../composables/formatters'
const route = useRoute()
const repository = ref(null)
const loading = ref(true)
const error = ref(null)
const breadcrumbs = computed(() => [
{ label: 'Dashboard', to: '/' },
{ label: 'Repositories' },
{ label: repository.value?.name || route.params.name }
])
const tableColumns = [
{ key: 'contributor', label: 'Contributor', align: 'left' },
{ key: 'commits', label: 'Commits', align: 'center' },
{ key: 'prs', label: 'PRs', align: 'center' },
{ key: 'reviews', label: 'Reviews', align: 'center' },
{ key: 'lines', label: 'Lines +/-', align: 'center' },
{ key: 'score', label: 'Score', align: 'right' }
]
async function loadRepository() {
loading.value = true
error.value = null
try {
const response = await fetch(`./data/repos/${route.params.owner}/${route.params.name}/metrics.json`)
if (!response.ok) throw new Error('Repository not found')
repository.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
onMounted(loadRepository)
watch(() => route.params, loadRepository)
</script>
<template>
<div>
<LoadingState v-if="loading" message="Loading repository..." />
<ErrorState v-else-if="error" :message="error" />
<template v-else-if="repository">
<PageHeader
:title="repository.name"
icon="fas fa-code-branch"
icon-color="text-accent-500"
:breadcrumbs="breadcrumbs"
>
<template #subtitle>
<GithubLink :url="`https://github.com/${repository.owner}/${repository.name}`">
{{ repository.owner }}/{{ repository.name }}
</GithubLink>
</template>
</PageHeader>
<!-- Stats -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
:value="repository.total_commits"
label="Commits"
icon="fas fa-code-commit"
icon-color="text-green-500"
/>
<StatCard
:value="repository.total_prs"
label="Pull Requests"
icon="fas fa-code-pull-request"
icon-color="text-blue-500"
/>
<StatCard
:value="repository.total_reviews"
label="Reviews"
icon="fas fa-eye"
icon-color="text-purple-500"
/>
<StatCard
:value="repository.active_contributors"
label="Contributors"
icon="fas fa-users"
icon-color="text-orange-500"
/>
</div>
</div>
</section>
<!-- Contributors -->
<section class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader title="Contributors" icon="fas fa-users" icon-color="text-blue-500" />
<DataTable
:columns="tableColumns"
:items="repository.contributors"
empty-icon="fas fa-users"
empty-message="No contributors found"
row-class="hover:bg-gray-50 dark:hover:bg-gray-800/30 transition group"
>
<template #contributor="{ item }">
<ContributorRow :contributor="item" />
</template>
<template #commits="{ item }">
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.commit_count) }}</span>
</template>
<template #prs="{ item }">
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.prs_opened) }}</span>
</template>
<template #reviews="{ item }">
<span class="text-gray-800 dark:text-white">{{ formatNumber(item.reviews_given) }}</span>
</template>
<template #lines="{ item }">
<span class="text-green-500">+{{ formatNumber(item.lines_added) }}</span>
<span class="text-gray-400 mx-1">/</span>
<span class="text-red-500">-{{ formatNumber(item.lines_deleted) }}</span>
</template>
<template #score="{ item }">
<span class="text-lg font-bold gradient-text">
{{ formatNumber(item.score?.total || 0) }}
</span>
</template>
</DataTable>
</div>
</section>
</template>
</div>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup>
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoute } from 'vue-router'
import PageHeader from '../components/PageHeader.vue'
import LoadingState from '../components/LoadingState.vue'
import ErrorState from '../components/ErrorState.vue'
import StatCard from '../components/StatCard.vue'
import MemberCard from '../components/MemberCard.vue'
import SectionHeader from '../components/SectionHeader.vue'
import { slugify } from '../composables/formatters'
const route = useRoute()
const globalData = inject('globalData')
const team = ref(null)
const loading = ref(true)
const error = ref(null)
const breadcrumbs = computed(() => [
{ label: 'Dashboard', to: '/' },
{ label: 'Teams' },
{ label: team.value?.name || route.params.slug }
])
function loadTeam() {
loading.value = true
error.value = null
const teams = globalData.value?.teams || []
const found = teams.find(t => slugify(t.name) === route.params.slug)
if (found) {
team.value = found
} else {
error.value = 'Team not found'
}
loading.value = false
}
onMounted(loadTeam)
watch(() => route.params, loadTeam)
watch(globalData, loadTeam)
</script>
<template>
<div>
<LoadingState v-if="loading" message="Loading team..." />
<ErrorState v-else-if="error" :message="error" />
<template v-else-if="team">
<PageHeader
:title="team.name"
:breadcrumbs="breadcrumbs"
:subtitle="`${team.members?.length || 0} team members`"
>
<template #prefix>
<div
class="w-4 h-4 rounded-full mr-4"
:style="{ backgroundColor: team.color || '#8b5cf6' }"
></div>
</template>
</PageHeader>
<!-- Team Stats -->
<section class="py-8 px-4">
<div class="container mx-auto">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
:value="team.total_score"
label="Total Score"
icon="fas fa-star"
icon-color="text-yellow-500"
/>
<StatCard
:value="team.aggregated_metrics?.commit_count || 0"
label="Commits"
icon="fas fa-code-commit"
icon-color="text-green-500"
/>
<StatCard
:value="team.aggregated_metrics?.prs_merged || 0"
label="PRs Merged"
icon="fas fa-code-merge"
icon-color="text-purple-500"
/>
<StatCard
:value="team.aggregated_metrics?.reviews_given || 0"
label="Reviews"
icon="fas fa-eye"
icon-color="text-blue-500"
/>
</div>
</div>
</section>
<!-- Team Members -->
<section class="py-8 px-4">
<div class="container mx-auto">
<SectionHeader title="Team Members" icon="fas fa-users" icon-color="text-blue-500" />
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<MemberCard
v-for="member in team.member_metrics"
:key="member.login"
:member="member"
/>
</div>
</div>
</section>
</template>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
colors: {
primary: {
50: '#fdf2f8',
100: '#fce7f3',
200: '#fbcfe8',
300: '#f9a8d4',
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
700: '#be185d',
800: '#9d174d',
900: '#831843',
},
accent: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7e22ce',
800: '#6b21a8',
900: '#581c87',
}
},
animation: {
'fade-in-up': 'fadeInUp 0.6s ease-out',
'float': 'float 3s ease-in-out infinite',
},
keyframes: {
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
}
}
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: './',
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
'chart': ['chart.js']
}
}
}
}
})