mirror of
https://github.com/lukaszraczylo/git-velocity.git
synced 2026-06-09 23:04:00 +00:00
Initial commit.
This commit is contained in:
@@ -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'
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
git-velocity
|
||||
.repos/
|
||||
.cache/
|
||||
dist/
|
||||
web/dist/
|
||||
web/public/data
|
||||
config.yaml
|
||||
.claude
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Vendored
+217
@@ -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{}{})
|
||||
}
|
||||
Vendored
+290
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package version
|
||||
|
||||
// Version information set at build time
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
@@ -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>
|
||||
Generated
+2303
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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, '')
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"> — </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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user