From 9d4de0e6b668531d116c71c53656ca9e0a6af81c Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Wed, 10 Dec 2025 21:09:25 +0000 Subject: [PATCH] Initial commit. --- .../workflows/example-velocity-analysis.yml | 29 + .github/workflows/release.yml | 52 + .gitignore | 9 + .goreleaser.yaml | 92 + Dockerfile | 11 + Makefile | 102 + config.example.yaml | 136 + docs/index.html | 500 ++++ go.mod | 43 + go.sum | 128 + internal/aggregator/aggregator.go | 1205 +++++++++ internal/aggregator/aggregator_test.go | 383 +++ internal/app/app.go | 326 +++ internal/config/config.go | 270 ++ internal/config/config_test.go | 920 +++++++ internal/config/schema.go | 454 ++++ internal/config/validation.go | 227 ++ internal/config/validation_test.go | 493 ++++ internal/domain/models/author.go | 24 + internal/domain/models/commit.go | 43 + internal/domain/models/issue.go | 54 + internal/domain/models/metrics.go | 208 ++ internal/domain/models/models_test.go | 398 +++ internal/domain/models/pullrequest.go | 107 + internal/domain/models/rawdata.go | 9 + internal/domain/models/review.go | 57 + internal/domain/scoring/calculator.go | 312 +++ internal/domain/scoring/calculator_test.go | 714 +++++ internal/generator/site/generator.go | 182 ++ internal/generator/site/generator_test.go | 502 ++++ internal/git/repository.go | 440 ++++ internal/github/cache/cache.go | 217 ++ internal/github/cache/cache_test.go | 290 +++ internal/github/client.go | 928 +++++++ internal/server/server.go | 77 + internal/server/server_test.go | 209 ++ pkg/version/version.go | 8 + web/index.html | 16 + web/package-lock.json | 2303 +++++++++++++++++ web/package.json | 23 + web/postcss.config.js | 6 + web/src/App.vue | 49 + web/src/components/AchievementBadge.vue | 179 ++ web/src/components/AchievementProgress.vue | 335 +++ web/src/components/Avatar.vue | 37 + web/src/components/Breadcrumb.vue | 35 + web/src/components/ContributorCard.vue | 78 + web/src/components/ContributorRow.vue | 54 + web/src/components/DataTable.vue | 85 + web/src/components/ErrorState.vue | 22 + web/src/components/Footer.vue | 35 + web/src/components/GithubLink.vue | 29 + web/src/components/LoadingState.vue | 17 + web/src/components/MemberCard.vue | 79 + web/src/components/Navbar.vue | 90 + web/src/components/PageHeader.vue | 52 + web/src/components/RankBadge.vue | 31 + web/src/components/RepoCard.vue | 47 + web/src/components/SectionHeader.vue | 23 + web/src/components/StatCard.vue | 28 + web/src/components/TeamCard.vue | 56 + web/src/components/ThemeToggle.vue | 48 + web/src/components/VelocityChart.vue | 168 ++ web/src/composables/formatters.js | 60 + web/src/main.js | 31 + web/src/style.css | 78 + web/src/views/Contributor.vue | 368 +++ web/src/views/Dashboard.vue | 157 ++ web/src/views/Leaderboard.vue | 104 + web/src/views/Repository.vue | 143 + web/src/views/Team.vue | 112 + web/tailwind.config.js | 57 + web/vite.config.js | 25 + 73 files changed, 15219 insertions(+) create mode 100644 .github/workflows/example-velocity-analysis.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 config.example.yaml create mode 100644 docs/index.html create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/aggregator/aggregator.go create mode 100644 internal/aggregator/aggregator_test.go create mode 100644 internal/app/app.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/schema.go create mode 100644 internal/config/validation.go create mode 100644 internal/config/validation_test.go create mode 100644 internal/domain/models/author.go create mode 100644 internal/domain/models/commit.go create mode 100644 internal/domain/models/issue.go create mode 100644 internal/domain/models/metrics.go create mode 100644 internal/domain/models/models_test.go create mode 100644 internal/domain/models/pullrequest.go create mode 100644 internal/domain/models/rawdata.go create mode 100644 internal/domain/models/review.go create mode 100644 internal/domain/scoring/calculator.go create mode 100644 internal/domain/scoring/calculator_test.go create mode 100644 internal/generator/site/generator.go create mode 100644 internal/generator/site/generator_test.go create mode 100644 internal/git/repository.go create mode 100644 internal/github/cache/cache.go create mode 100644 internal/github/cache/cache_test.go create mode 100644 internal/github/client.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 pkg/version/version.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/src/App.vue create mode 100644 web/src/components/AchievementBadge.vue create mode 100644 web/src/components/AchievementProgress.vue create mode 100644 web/src/components/Avatar.vue create mode 100644 web/src/components/Breadcrumb.vue create mode 100644 web/src/components/ContributorCard.vue create mode 100644 web/src/components/ContributorRow.vue create mode 100644 web/src/components/DataTable.vue create mode 100644 web/src/components/ErrorState.vue create mode 100644 web/src/components/Footer.vue create mode 100644 web/src/components/GithubLink.vue create mode 100644 web/src/components/LoadingState.vue create mode 100644 web/src/components/MemberCard.vue create mode 100644 web/src/components/Navbar.vue create mode 100644 web/src/components/PageHeader.vue create mode 100644 web/src/components/RankBadge.vue create mode 100644 web/src/components/RepoCard.vue create mode 100644 web/src/components/SectionHeader.vue create mode 100644 web/src/components/StatCard.vue create mode 100644 web/src/components/TeamCard.vue create mode 100644 web/src/components/ThemeToggle.vue create mode 100644 web/src/components/VelocityChart.vue create mode 100644 web/src/composables/formatters.js create mode 100644 web/src/main.js create mode 100644 web/src/style.css create mode 100644 web/src/views/Contributor.vue create mode 100644 web/src/views/Dashboard.vue create mode 100644 web/src/views/Leaderboard.vue create mode 100644 web/src/views/Repository.vue create mode 100644 web/src/views/Team.vue create mode 100644 web/tailwind.config.js create mode 100644 web/vite.config.js diff --git a/.github/workflows/example-velocity-analysis.yml b/.github/workflows/example-velocity-analysis.yml new file mode 100644 index 0000000..b314b9a --- /dev/null +++ b/.github/workflows/example-velocity-analysis.yml @@ -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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..71b5aa6 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..923940b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +git-velocity +.repos/ +.cache/ +dist/ +web/dist/ +web/public/data +config.yaml +.claude diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..000018b --- /dev/null +++ b/.goreleaser.yaml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a689bf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1cddd49 --- /dev/null +++ b/Makefile @@ -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" diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..5881201 --- /dev/null +++ b/config.example.yaml @@ -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*" diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..b104d8d --- /dev/null +++ b/docs/index.html @@ -0,0 +1,500 @@ + + + + + + Git Velocity - Developer Metrics & Gamification Dashboard + + + + + + + + + + + + + + + +
+
+
+
+
+ +
+
+
+
+ +
+
+

+ Developer Velocity
Dashboard +

+

+ Analyze GitHub repositories for developer metrics with gamification. Generate beautiful dashboards with leaderboards, achievements, and team insights. +

+ +
+ Version + License + Go Report +
+
+
+
+
+
+
+
+
+ terminal +
+
$ git-velocity analyze --config config.yaml
+Fetching data from GitHub...
+Processing 3 repositories...
+Generated dashboard at ./dist
+
+$ git-velocity serve --port 8080
+Starting preview server at http://localhost:8080
+
+
+
+
+
+
+ + +
+
+
+

Features

+

Everything you need to track developer productivity

+
+
+
+
+
+ +
+
+

Velocity Metrics

+

Track commits, PRs, reviews, and code changes over time

+
+
+
+
+
+
+ +
+
+

Gamification

+

Score points, earn achievements, and compete on leaderboards

+
+
+
+
+
+
+ +
+
+

Team Insights

+

Configure teams and see aggregated team metrics

+
+
+
+
+
+
+ +
+
+

GitHub Action

+

Run analysis automatically in your CI/CD pipeline

+
+
+
+
+
+
+ +
+
+

Static Site

+

Generate a beautiful Vue.js SPA dashboard

+
+
+
+
+
+
+ +
+
+

Fast & Cached

+

File-based caching and concurrent API requests

+
+
+
+
+
+
+ + +
+
+
+

Installation

+

Get started in seconds

+
+
+
+

+ + Go Install +

+
go install github.com/lukaszraczylo/git-velocity/cmd/git-velocity@latest
+
+
+

+ + Download Binary +

+

Download from the releases page.

+

Supported: macOS, Linux, Windows (amd64, arm64)

+
+
+

+ + GitHub Action +

+
- uses: lukaszraczylo/git-velocity/.github/actions/git-velocity@main
+  with:
+    github_token: ${{ secrets.GITHUB_TOKEN }}
+    config_file: '.git-velocity.yaml'
+
+
+
+
+ + +
+
+
+

Usage

+

Simple CLI commands

+
+
+
+

+ + CLI Commands +

+
# Analyze repositories and generate dashboard
+git-velocity analyze --config config.yaml --output ./dist
+
+# Start local preview server
+git-velocity serve --directory ./dist --port 8080
+
+# Show version
+git-velocity version
+
+
+

Analyze Flags

+
    +
  • -c, --config Path to config file
  • +
  • -o, --output Output directory
  • +
  • -v, --verbose Verbose output
  • +
+
+
+

Serve Flags

+
    +
  • -d, --directory Directory to serve
  • +
  • -p, --port Port to listen on
  • +
+
+
+
+ +
+

+ + GitHub Action Example +

+
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'
+
+
+
+
+ + +
+
+
+

What Gets Tracked

+

Comprehensive developer activity metrics

+
+
+
+
+
+ +

Commits

+
+

Total commits, lines added/deleted, files changed

+
+
+
+ +

Pull Requests

+
+

PRs opened, merged, closed, average size

+
+
+
+ +

Code Reviews

+
+

Reviews given, comments, approvals, response time

+
+
+
+ +

Issues

+
+

Issues opened, closed, comments

+
+
+
+
+
+ + +
+
+
+

Configuration

+

Customize everything via YAML

+
+
+
+

.git-velocity.yaml

+
version: "1.0"
+
+auth:
+  github_token: "${GITHUB_TOKEN}"
+
+repositories:
+  - owner: "your-org"
+    name: "your-repo"
+
+teams:
+  - name: "Backend Team"
+    members: ["dev1", "dev2"]
+    color: "#3B82F6"
+
+scoring:
+  enabled: true
+  points:
+    commit: 10
+    pr_opened: 25
+    pr_merged: 50
+    pr_reviewed: 30
+
+output:
+  directory: "./dist"
+  deploy:
+    gh_pages: true
+
+
+
+
+ + + + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..81303c4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a3d3744 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go new file mode 100644 index 0000000..a257174 --- /dev/null +++ b/internal/aggregator/aggregator.go @@ -0,0 +1,1205 @@ +package aggregator + +import ( + "sort" + "strings" + "time" + + "github.com/lukaszraczylo/git-velocity/internal/config" + "github.com/lukaszraczylo/git-velocity/internal/domain/models" +) + +// UserProfile contains GitHub user profile information 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 +} + +// Aggregator handles metrics aggregation +type Aggregator struct { + config *config.Config + userProfiles map[string]UserProfile // GitHub login -> profile +} + +// New creates a new Aggregator +func New(cfg *config.Config) *Aggregator { + return &Aggregator{ + config: cfg, + userProfiles: make(map[string]UserProfile), + } +} + +// SetUserProfiles sets the user profiles for enhanced deduplication +func (a *Aggregator) SetUserProfiles(profiles map[string]UserProfile) { + a.userProfiles = profiles +} + +// Aggregate processes raw data and produces global metrics +func (a *Aggregator) Aggregate(data *models.RawData, dateRange *config.ParsedDateRange) (*models.GlobalMetrics, error) { + period := models.Period{ + End: time.Now(), + Granularity: "all", + Label: "All Time", + } + + if dateRange.Start != nil { + period.Start = *dateRange.Start + } + if dateRange.End != nil { + period.End = *dateRange.End + } + + // Build email-to-login mapping from PRs and reviews (these have real GitHub logins) + // This helps normalize commit authors to their GitHub usernames + emailToLogin := buildEmailToLoginMapping(data, a.userProfiles) + + // Build login-to-login mapping for sanitized logins (e.g., lukasz-raczylo -> lukaszraczylo) + // Also returns verified login info with avatar URLs + loginToLogin, loginToInfo := buildLoginMapping(data) + + // Build contributor map (global stats across all repos) + contributorMap := make(map[string]*models.ContributorMetrics) + repoMap := make(map[string]*models.RepositoryMetrics) + + // Per-repository contributor maps (repo -> login -> metrics) + repoContributorMap := make(map[string]map[string]*models.ContributorMetrics) + + // Track activity days per contributor for streak calculation + activityDays := make(map[string]map[string]bool) // login -> set of date strings + // Per-repo activity days + repoActivityDays := make(map[string]map[string]map[string]bool) // repo -> login -> set of date strings + + // Helper to get or create per-repo contributor + getRepoContributor := func(repo, login, name, avatarURL string) *models.ContributorMetrics { + if repoContributorMap[repo] == nil { + repoContributorMap[repo] = make(map[string]*models.ContributorMetrics) + } + if _, ok := repoContributorMap[repo][login]; !ok { + repoContributorMap[repo][login] = &models.ContributorMetrics{ + Login: login, + Name: name, + AvatarURL: avatarURL, + Period: period, + } + } + return repoContributorMap[repo][login] + } + + // Process commits + for _, commit := range data.Commits { + login := commit.Author.Login + if login == "" { + continue + } + + // Normalize login using email mapping (prefer GitHub login over git-derived login) + if mappedLogin, ok := emailToLogin[commit.Author.Email]; ok { + login = mappedLogin + } + + // Also check login-to-login mapping for sanitized logins + if mappedLogin, ok := loginToLogin[login]; ok { + login = mappedLogin + } + + // Initialize contributor if needed + if _, ok := contributorMap[login]; !ok { + name := commit.Author.Name + avatarURL := commit.Author.AvatarURL + + // Use verified info if available (has better name/avatar from GitHub API) + if info, exists := loginToInfo[login]; exists { + if info.Name != "" { + name = info.Name + } + if info.AvatarURL != "" { + avatarURL = info.AvatarURL + } + } + + // If still no name, use login as display name + if name == "" { + name = login + } + + contributorMap[login] = &models.ContributorMetrics{ + Login: login, + Name: name, + AvatarURL: avatarURL, + Period: period, + } + } + + cm := contributorMap[login] + cm.CommitCount++ + cm.LinesAdded += commit.Additions + cm.LinesDeleted += commit.Deletions + cm.FilesChanged += commit.FilesChanged + + // Update per-repo contributor stats + rcm := getRepoContributor(commit.Repository, login, cm.Name, cm.AvatarURL) + rcm.CommitCount++ + rcm.LinesAdded += commit.Additions + rcm.LinesDeleted += commit.Deletions + rcm.FilesChanged += commit.FilesChanged + + // Track activity patterns based on commit time + hour := commit.Date.Hour() + weekday := commit.Date.Weekday() + + // Early bird: commits before 9am + if hour >= 5 && hour < 9 { + cm.EarlyBirdCount++ + rcm.EarlyBirdCount++ + } + // Night owl: commits after 9pm + if hour >= 21 || hour < 5 { + cm.NightOwlCount++ + rcm.NightOwlCount++ + } + // Nosferatu: commits between midnight and 4am + if hour >= 0 && hour < 4 { + cm.MidnightCount++ + rcm.MidnightCount++ + } + // Weekend warrior + if weekday == time.Saturday || weekday == time.Sunday { + cm.WeekendWarrior++ + rcm.WeekendWarrior++ + } + + // Track activity days (global) + if activityDays[login] == nil { + activityDays[login] = make(map[string]bool) + } + dateStr := commit.Date.Format("2006-01-02") + activityDays[login][dateStr] = true + + // Track activity days (per-repo) + if repoActivityDays[commit.Repository] == nil { + repoActivityDays[commit.Repository] = make(map[string]map[string]bool) + } + if repoActivityDays[commit.Repository][login] == nil { + repoActivityDays[commit.Repository][login] = make(map[string]bool) + } + repoActivityDays[commit.Repository][login][dateStr] = true + + // Track repository participation + if !contains(cm.RepositoriesContributed, commit.Repository) { + cm.RepositoriesContributed = append(cm.RepositoriesContributed, commit.Repository) + } + + // Update repository metrics + a.updateRepoMetrics(repoMap, commit.Repository, period) + rm := repoMap[commit.Repository] + rm.TotalCommits++ + rm.TotalLinesAdded += commit.Additions + rm.TotalLinesDeleted += commit.Deletions + } + + // Calculate active days and streaks for each contributor + for login, days := range activityDays { + if cm, ok := contributorMap[login]; ok { + cm.ActiveDays = len(days) + cm.LongestStreak, cm.CurrentStreak = calculateStreaks(days) + } + } + + // Track PRs with changes requested per contributor + prChangesRequested := make(map[string]map[int]bool) // login -> set of PR numbers with changes requested + + // Process pull requests + for _, pr := range data.PullRequests { + login := pr.Author.Login + if login == "" { + continue + } + + // Initialize contributor if needed + if _, ok := contributorMap[login]; !ok { + contributorMap[login] = &models.ContributorMetrics{ + Login: login, + Name: pr.Author.Name, + AvatarURL: pr.Author.AvatarURL, + Period: period, + } + } + + cm := contributorMap[login] + cm.PRsOpened++ + + // Get per-repo contributor + rcm := getRepoContributor(pr.Repository, login, cm.Name, cm.AvatarURL) + rcm.PRsOpened++ + + prSize := pr.Additions + pr.Deletions + + if pr.IsMerged() { + cm.PRsMerged++ + rcm.PRsMerged++ + if pr.TimeToMerge != nil { + // Accumulate for average calculation + cm.AvgTimeToMerge += pr.TimeToMerge.Hours() + rcm.AvgTimeToMerge += pr.TimeToMerge.Hours() + } + + // Track largest PR + if prSize > cm.LargestPRSize { + cm.LargestPRSize = prSize + } + if prSize > rcm.LargestPRSize { + rcm.LargestPRSize = prSize + } + + // Track small PRs (under 100 lines - good practice) + if prSize < 100 { + cm.SmallPRCount++ + rcm.SmallPRCount++ + } + } else if pr.State == models.PRStateClosed { + cm.PRsClosed++ + rcm.PRsClosed++ + } + + // Track repository participation + if !contains(cm.RepositoriesContributed, pr.Repository) { + cm.RepositoriesContributed = append(cm.RepositoriesContributed, pr.Repository) + } + + // Update repository metrics + a.updateRepoMetrics(repoMap, pr.Repository, period) + rm := repoMap[pr.Repository] + rm.TotalPRs++ + } + + // Process reviews + reviewerReviewees := make(map[string]map[string]bool) // reviewer -> set of reviewees + for _, review := range data.Reviews { + login := review.Author.Login + if login == "" { + continue + } + + // Initialize contributor if needed + if _, ok := contributorMap[login]; !ok { + contributorMap[login] = &models.ContributorMetrics{ + Login: login, + Period: period, + } + } + + cm := contributorMap[login] + cm.ReviewsGiven++ + cm.ReviewComments += review.CommentsCount + + // Get per-repo contributor + rcm := getRepoContributor(review.Repository, login, cm.Name, cm.AvatarURL) + rcm.ReviewsGiven++ + rcm.ReviewComments += review.CommentsCount + + if review.IsApproval() { + cm.ApprovalsGiven++ + rcm.ApprovalsGiven++ + } else if review.RequestsChanges() { + cm.ChangesRequested++ + rcm.ChangesRequested++ + + // Track which PRs had changes requested (for calculating "perfect PRs" for the PR author) + for _, pr := range data.PullRequests { + if pr.Number == review.PullRequest && pr.Repository == review.Repository { + prAuthor := pr.Author.Login + if prChangesRequested[prAuthor] == nil { + prChangesRequested[prAuthor] = make(map[int]bool) + } + prChangesRequested[prAuthor][pr.Number] = true + break + } + } + } + + if review.ResponseTime != nil { + cm.AvgReviewTime += review.ResponseTime.Hours() + rcm.AvgReviewTime += review.ResponseTime.Hours() + } + + // Track unique reviewees + if reviewerReviewees[login] == nil { + reviewerReviewees[login] = make(map[string]bool) + } + + // Find PR author (reviewee) + for _, pr := range data.PullRequests { + if pr.Number == review.PullRequest && pr.Repository == review.Repository { + reviewerReviewees[login][pr.Author.Login] = true + break + } + } + + // Update repository metrics + a.updateRepoMetrics(repoMap, review.Repository, period) + rm := repoMap[review.Repository] + rm.TotalReviews++ + } + + // Calculate perfect PRs (merged PRs without changes requested) for each contributor + for login, cm := range contributorMap { + changesRequestedPRs := prChangesRequested[login] + // Count merged PRs that didn't have changes requested + for _, pr := range data.PullRequests { + if pr.Author.Login == login && pr.IsMerged() { + if changesRequestedPRs == nil || !changesRequestedPRs[pr.Number] { + cm.PerfectPRs++ + } + } + } + } + + // Process issues + for _, issue := range data.Issues { + login := issue.Author.Login + if login == "" { + continue + } + + // Initialize contributor if needed + if _, ok := contributorMap[login]; !ok { + contributorMap[login] = &models.ContributorMetrics{ + Login: login, + Period: period, + } + } + + cm := contributorMap[login] + cm.IssuesOpened++ + + if issue.IsClosed() && issue.ClosedBy != nil && issue.ClosedBy.Login == login { + cm.IssuesClosed++ + } + + // Track repository participation + if !contains(cm.RepositoriesContributed, issue.Repository) { + cm.RepositoriesContributed = append(cm.RepositoriesContributed, issue.Repository) + } + } + + // Calculate averages and finalize contributor metrics + for login, cm := range contributorMap { + // Calculate average time to merge + if cm.PRsMerged > 0 { + cm.AvgTimeToMerge = cm.AvgTimeToMerge / float64(cm.PRsMerged) + } + + // Calculate average review time + if cm.ReviewsGiven > 0 { + cm.AvgReviewTime = cm.AvgReviewTime / float64(cm.ReviewsGiven) + } + + // Calculate average PR size + if cm.PRsOpened > 0 { + totalPRLines := 0 + for _, pr := range data.PullRequests { + if pr.Author.Login == login { + totalPRLines += pr.TotalChanges() + } + } + cm.AvgPRSize = float64(totalPRLines) / float64(cm.PRsOpened) + } + + // Set unique reviewees count + if reviewees, ok := reviewerReviewees[login]; ok { + cm.UniqueReviewees = len(reviewees) + } + } + + // Convert maps to slices + var contributors []models.ContributorMetrics + for _, cm := range contributorMap { + contributors = append(contributors, *cm) + } + + // Sort contributors by commit count + sort.Slice(contributors, func(i, j int) bool { + return contributors[i].CommitCount > contributors[j].CommitCount + }) + + // Calculate per-repo contributor averages and streaks + for repo, repoContribs := range repoContributorMap { + // Calculate active days and streaks for per-repo contributors + if repoDays, ok := repoActivityDays[repo]; ok { + for login, days := range repoDays { + if rcm, ok := repoContribs[login]; ok { + rcm.ActiveDays = len(days) + rcm.LongestStreak, rcm.CurrentStreak = calculateStreaks(days) + } + } + } + + // Calculate averages for per-repo contributors + for login, rcm := range repoContribs { + if rcm.PRsMerged > 0 { + rcm.AvgTimeToMerge = rcm.AvgTimeToMerge / float64(rcm.PRsMerged) + } + if rcm.ReviewsGiven > 0 { + rcm.AvgReviewTime = rcm.AvgReviewTime / float64(rcm.ReviewsGiven) + } + + // Calculate average PR size for this repo + if rcm.PRsOpened > 0 { + totalPRLines := 0 + for _, pr := range data.PullRequests { + if pr.Author.Login == login && pr.Repository == repo { + totalPRLines += pr.TotalChanges() + } + } + rcm.AvgPRSize = float64(totalPRLines) / float64(rcm.PRsOpened) + } + + // Calculate perfect PRs for this repo + for _, pr := range data.PullRequests { + if pr.Author.Login == login && pr.Repository == repo && pr.IsMerged() { + changesRequestedPRs := prChangesRequested[login] + if changesRequestedPRs == nil || !changesRequestedPRs[pr.Number] { + rcm.PerfectPRs++ + } + } + } + } + } + + var repositories []models.RepositoryMetrics + for _, rm := range repoMap { + // Add per-repo contributors (with repo-specific stats) + if repoContribs, ok := repoContributorMap[rm.FullName]; ok { + for _, rcm := range repoContribs { + rm.Contributors = append(rm.Contributors, *rcm) + } + } + // Sort contributors by commit count + sort.Slice(rm.Contributors, func(i, j int) bool { + return rm.Contributors[i].CommitCount > rm.Contributors[j].CommitCount + }) + rm.ActiveContributors = len(rm.Contributors) + repositories = append(repositories, *rm) + } + + // Build team metrics + var teams []models.TeamMetrics + for _, teamCfg := range a.config.Teams { + team := models.TeamMetrics{ + Name: teamCfg.Name, + Color: teamCfg.Color, + Members: teamCfg.Members, + Period: period, + } + + var totalScore int + for _, member := range teamCfg.Members { + if cm, ok := contributorMap[member]; ok { + team.MemberMetrics = append(team.MemberMetrics, *cm) + totalScore += cm.Score.Total + + // Aggregate team metrics + team.AggregatedMetrics.CommitCount += cm.CommitCount + team.AggregatedMetrics.LinesAdded += cm.LinesAdded + team.AggregatedMetrics.LinesDeleted += cm.LinesDeleted + team.AggregatedMetrics.PRsOpened += cm.PRsOpened + team.AggregatedMetrics.PRsMerged += cm.PRsMerged + team.AggregatedMetrics.ReviewsGiven += cm.ReviewsGiven + } + } + + team.TotalScore = totalScore + if len(team.MemberMetrics) > 0 { + team.AvgScore = float64(totalScore) / float64(len(team.MemberMetrics)) + } + + teams = append(teams, team) + } + + // Calculate totals + var totalCommits, totalPRs, totalReviews, totalLinesAdded, totalLinesDeleted int + for _, rm := range repositories { + totalCommits += rm.TotalCommits + totalPRs += rm.TotalPRs + totalReviews += rm.TotalReviews + totalLinesAdded += rm.TotalLinesAdded + totalLinesDeleted += rm.TotalLinesDeleted + } + + // Build velocity timeline (weekly aggregation) + velocityTimeline := buildVelocityTimeline(data, period, a.config.Scoring) + + return &models.GlobalMetrics{ + Period: period, + Repositories: repositories, + Teams: teams, + TotalContributors: len(contributors), + TotalCommits: totalCommits, + TotalPRs: totalPRs, + TotalReviews: totalReviews, + TotalLinesAdded: totalLinesAdded, + TotalLinesDeleted: totalLinesDeleted, + VelocityTimeline: velocityTimeline, + }, nil +} + +func (a *Aggregator) updateRepoMetrics(repoMap map[string]*models.RepositoryMetrics, fullName string, period models.Period) { + if _, ok := repoMap[fullName]; !ok { + owner, name := parseRepoName(fullName) + repoMap[fullName] = &models.RepositoryMetrics{ + Owner: owner, + Name: name, + FullName: fullName, + Period: period, + } + } +} + +func parseRepoName(fullName string) (owner, name string) { + for i, c := range fullName { + if c == '/' { + return fullName[:i], fullName[i+1:] + } + } + return fullName, "" +} + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// normalizeForComparison normalizes a string for fuzzy comparison +// by lowercasing and removing spaces, hyphens, underscores, dots, and digits +func normalizeForComparison(s string) string { + var result []rune + for _, r := range strings.ToLower(s) { + if r >= 'a' && r <= 'z' { + result = append(result, r) + } + } + return string(result) +} + +// buildEmailToLoginMapping creates mappings to normalize authors to GitHub logins +// Strategy: +// 1. Build map of GitHub user ID -> login from PR/review data +// 2. Build map of email -> login from user profiles (fetched from GitHub API) +// 3. Parse GitHub noreply emails (ID+username@users.noreply.github.com) and map via ID +// 4. For each email, collect all author names used with that email +// 5. If ANY name used with an email matches a verified login (case-insensitive), map that email to that login +// 6. Map remaining emails by author name matching +func buildEmailToLoginMapping(data *models.RawData, userProfiles map[string]UserProfile) map[string]string { + mapping := make(map[string]string) + + // Build map of GitHub user ID -> login info from PR/review data + idToLogin := make(map[int64]string) + verifiedLogins := make(map[string]string) // lowercase -> original case + for _, pr := range data.PullRequests { + if pr.Author.Login != "" { + verifiedLogins[strings.ToLower(pr.Author.Login)] = pr.Author.Login + if pr.Author.ID != 0 { + idToLogin[pr.Author.ID] = pr.Author.Login + } + } + } + for _, review := range data.Reviews { + if review.Author.Login != "" { + if _, exists := verifiedLogins[strings.ToLower(review.Author.Login)]; !exists { + verifiedLogins[strings.ToLower(review.Author.Login)] = review.Author.Login + } + if review.Author.ID != 0 { + if _, exists := idToLogin[review.Author.ID]; !exists { + idToLogin[review.Author.ID] = review.Author.Login + } + } + } + } + + // Build email -> login mapping from user profiles (public emails from GitHub profiles) + // This is the most reliable way to match users who have different emails + profileEmailToLogin := make(map[string]string) + profileNameToLogin := make(map[string]string) + for _, profile := range userProfiles { + if profile.Email != "" { + profileEmailToLogin[strings.ToLower(profile.Email)] = profile.Login + } + // Also map by ID from profile + if profile.ID != 0 { + idToLogin[profile.ID] = profile.Login + } + // Map by name (for fuzzy matching later) + if profile.Name != "" { + profileNameToLogin[strings.ToLower(profile.Name)] = profile.Login + } + } + + // First pass: handle GitHub noreply emails via user ID (most reliable) + // Format: ID+username@users.noreply.github.com + for _, commit := range data.Commits { + email := commit.Author.Email + if email == "" || !strings.Contains(email, "@users.noreply.github.com") { + continue + } + + localPart := strings.Split(email, "@")[0] + var idStr, loginFromEmail string + if idx := strings.Index(localPart, "+"); idx != -1 { + idStr = localPart[:idx] + loginFromEmail = localPart[idx+1:] + } else { + // Could be just numeric ID + idStr = localPart + } + + // Try to parse numeric ID + var id int64 + for _, c := range idStr { + if c >= '0' && c <= '9' { + id = id*10 + int64(c-'0') + } else { + id = 0 + break + } + } + + // Map via ID first (most reliable) + if id != 0 { + if login, ok := idToLogin[id]; ok { + mapping[email] = login + continue + } + } + + // Fallback to username from email + if loginFromEmail != "" { + mapping[email] = loginFromEmail + } + } + + // Second pass: Check commit emails against profile emails (from GitHub API) + // This handles cases where users have multiple emails (org, personal, etc.) + for _, commit := range data.Commits { + email := commit.Author.Email + if email == "" || mapping[email] != "" { + continue + } + + // Check if this email matches any profile's public email + emailLower := strings.ToLower(email) + if login, ok := profileEmailToLogin[emailLower]; ok { + mapping[email] = login + continue + } + + // Also check by name against profile names + if commit.Author.Name != "" { + nameLower := strings.ToLower(commit.Author.Name) + if login, ok := profileNameToLogin[nameLower]; ok { + mapping[email] = login + } + } + } + + // Build email -> set of author names/logins used with that email + emailToNames := make(map[string]map[string]bool) + for _, commit := range data.Commits { + email := commit.Author.Email + if email == "" { + continue + } + if emailToNames[email] == nil { + emailToNames[email] = make(map[string]bool) + } + if commit.Author.Name != "" { + emailToNames[email][commit.Author.Name] = true + } + if commit.Author.Login != "" { + emailToNames[email][commit.Author.Login] = true + } + } + + // For each email not yet mapped, check if ANY name matches a verified login + for email, names := range emailToNames { + if mapping[email] != "" { + continue + } + for name := range names { + // Clean up name (remove quotes, trim) + nameLower := strings.ToLower(strings.Trim(name, "\"' ")) + if verifiedLogin, ok := verifiedLogins[nameLower]; ok { + mapping[email] = verifiedLogin + break + } + } + + // Still not mapped? Try fuzzy matching by normalizing name (removing spaces, hyphens) + if mapping[email] == "" { + for name := range names { + // Normalize: lowercase, remove spaces, hyphens, underscores + normalized := normalizeForComparison(name) + for verifiedLower, verifiedLogin := range verifiedLogins { + if normalized == normalizeForComparison(verifiedLower) { + mapping[email] = verifiedLogin + break + } + } + if mapping[email] != "" { + break + } + } + } + + // Still not mapped? Try extracting email username for matching + if mapping[email] == "" { + emailLower := strings.ToLower(email) + if idx := strings.Index(emailLower, "@"); idx > 0 { + emailUser := emailLower[:idx] + // Remove common suffixes like numbers + emailUserNorm := normalizeForComparison(emailUser) + for verifiedLower, verifiedLogin := range verifiedLogins { + verifiedNorm := normalizeForComparison(verifiedLower) + // Check if email username is similar to verified login + if emailUserNorm == verifiedNorm || strings.HasPrefix(emailUserNorm, verifiedNorm) || strings.HasPrefix(verifiedNorm, emailUserNorm) { + mapping[email] = verifiedLogin + break + } + } + } + } + } + + // Build name-to-login mapping for remaining matches + nameToLogin := make(map[string]string) + for _, pr := range data.PullRequests { + if pr.Author.Login != "" { + if pr.Author.Name != "" { + nameToLogin[strings.ToLower(pr.Author.Name)] = pr.Author.Login + } + nameToLogin[strings.ToLower(pr.Author.Login)] = pr.Author.Login + } + } + for _, review := range data.Reviews { + if review.Author.Login != "" { + if review.Author.Name != "" { + if _, exists := nameToLogin[strings.ToLower(review.Author.Name)]; !exists { + nameToLogin[strings.ToLower(review.Author.Name)] = review.Author.Login + } + } + if _, exists := nameToLogin[strings.ToLower(review.Author.Login)]; !exists { + nameToLogin[strings.ToLower(review.Author.Login)] = review.Author.Login + } + } + } + + // Also add name mappings from GitHub noreply emails + for _, commit := range data.Commits { + if mapping[commit.Author.Email] != "" && commit.Author.Name != "" { + nameToLogin[strings.ToLower(commit.Author.Name)] = mapping[commit.Author.Email] + } + } + + // Final pass: map remaining emails by author name + for _, commit := range data.Commits { + email := commit.Author.Email + if email == "" || mapping[email] != "" { + continue + } + + // Try to find by name (case-insensitive) + if login, ok := nameToLogin[strings.ToLower(commit.Author.Name)]; ok { + mapping[email] = login + } + } + + return mapping +} + +// loginInfo stores verified GitHub login info +type loginInfo struct { + Login string + Name string + AvatarURL string +} + +// buildLoginMapping converts potentially sanitized logins to real GitHub logins +// using known mappings from PR/review data, and returns avatar URLs +func buildLoginMapping(data *models.RawData) (map[string]string, map[string]loginInfo) { + loginMapping := make(map[string]string) + nameToLoginInfo := make(map[string]loginInfo) + loginToInfo := make(map[string]loginInfo) + idToLoginInfo := make(map[int64]loginInfo) // Map GitHub user ID to login info + + // Collect verified GitHub logins from PRs and reviews + for _, pr := range data.PullRequests { + if pr.Author.Login != "" { + info := loginInfo{ + Login: pr.Author.Login, + Name: pr.Author.Name, + AvatarURL: pr.Author.AvatarURL, + } + loginToInfo[pr.Author.Login] = info + if pr.Author.ID != 0 { + idToLoginInfo[pr.Author.ID] = info + } + if pr.Author.Name != "" { + nameToLoginInfo[strings.ToLower(pr.Author.Name)] = info + } + } + } + for _, review := range data.Reviews { + if review.Author.Login != "" { + // Only set if not already set (PRs have higher priority) + if _, exists := loginToInfo[review.Author.Login]; !exists { + info := loginInfo{ + Login: review.Author.Login, + Name: review.Author.Name, + AvatarURL: review.Author.AvatarURL, + } + loginToInfo[review.Author.Login] = info + if review.Author.ID != 0 { + if _, exists := idToLoginInfo[review.Author.ID]; !exists { + idToLoginInfo[review.Author.ID] = info + } + } + if review.Author.Name != "" { + if _, exists := nameToLoginInfo[strings.ToLower(review.Author.Name)]; !exists { + nameToLoginInfo[strings.ToLower(review.Author.Name)] = info + } + } + } + } + } + + // Build email-to-verifiedLogin mapping from commits with noreply emails + // This helps link personal commits to verified GitHub users + emailToVerified := make(map[string]string) + for _, commit := range data.Commits { + email := commit.Author.Email + if email == "" || !strings.Contains(email, "@users.noreply.github.com") { + continue + } + localPart := strings.Split(email, "@")[0] + var login string + if idx := strings.Index(localPart, "+"); idx != -1 { + login = localPart[idx+1:] + } else { + login = localPart + } + if login != "" { + // Map this author's name to verified login + if commit.Author.Name != "" { + nameToLoginInfo[strings.ToLower(commit.Author.Name)] = loginInfo{Login: login} + } + } + } + _ = emailToVerified // suppress unused warning + + // Build a name-to-commit-login map from commits (for reverse lookup) + // This helps map PR logins (no name) back to commit logins (has name) + commitNameToLogin := make(map[string]string) + for _, commit := range data.Commits { + if commit.Author.Name != "" && commit.Author.Login != "" { + nameLower := strings.ToLower(commit.Author.Name) + // Only set if not already a verified login + if _, isVerified := loginToInfo[commit.Author.Login]; !isVerified { + if existing, exists := commitNameToLogin[nameLower]; !exists || len(commit.Author.Login) < len(existing) { + commitNameToLogin[nameLower] = commit.Author.Login + } + } + } + } + + // For each commit, check if its login can be mapped to a verified login + for _, commit := range data.Commits { + commitLogin := commit.Author.Login + if commitLogin == "" { + continue + } + + // If the commit login already matches a verified login, skip + if _, exists := loginToInfo[commitLogin]; exists { + continue + } + + // Already mapped? + if _, exists := loginMapping[commitLogin]; exists { + continue + } + + // Strategy 1 (BEST): Try to map via GitHub user ID from noreply email + // Format: ID+username@users.noreply.github.com or just ID@users.noreply.github.com + if commit.Author.Email != "" && strings.Contains(commit.Author.Email, "@users.noreply.github.com") { + localPart := strings.Split(commit.Author.Email, "@")[0] + // Try to extract numeric ID from start of local part + var idStr string + if idx := strings.Index(localPart, "+"); idx != -1 { + idStr = localPart[:idx] + } else { + // Might be just the ID without username + idStr = localPart + } + + // Parse ID and look up + var id int64 + for _, c := range idStr { + if c >= '0' && c <= '9' { + id = id*10 + int64(c-'0') + } else { + id = 0 + break + } + } + + if id != 0 { + if info, ok := idToLoginInfo[id]; ok { + if commitLogin != info.Login { + loginMapping[commitLogin] = info.Login + continue + } + } + } + } + + // Strategy 2: Try to map via author name + if commit.Author.Name != "" { + if info, ok := nameToLoginInfo[strings.ToLower(commit.Author.Name)]; ok { + if commitLogin != info.Login { + loginMapping[commitLogin] = info.Login + continue + } + } + } + + // Strategy 3: Check if commitLogin is a sanitized version of any verified login + // e.g., "lukasz-raczylo" might be sanitized from "lukaszraczylo" + // Compare by removing hyphens and lowercasing + sanitizedCommit := strings.ToLower(strings.ReplaceAll(commitLogin, "-", "")) + for verifiedLogin := range loginToInfo { + sanitizedVerified := strings.ToLower(strings.ReplaceAll(verifiedLogin, "-", "")) + if sanitizedCommit == sanitizedVerified && commitLogin != verifiedLogin { + loginMapping[commitLogin] = verifiedLogin + break + } + } + } + + // Strategy 4: For each commit name, find if a different commit login (hyphenated) + // can be mapped to the verified login via sanitized comparison + // This catches cases missed by the main loop + for _, commitLogin := range commitNameToLogin { + if _, exists := loginToInfo[commitLogin]; exists { + // This commit login is already verified, skip + continue + } + if _, exists := loginMapping[commitLogin]; exists { + // Already mapped + continue + } + + // Check if removing hyphens matches a verified login + sanitizedCommit := strings.ToLower(strings.ReplaceAll(commitLogin, "-", "")) + for verifiedLogin := range loginToInfo { + sanitizedVerified := strings.ToLower(strings.ReplaceAll(verifiedLogin, "-", "")) + if sanitizedCommit == sanitizedVerified && commitLogin != verifiedLogin { + loginMapping[commitLogin] = verifiedLogin + break + } + } + } + + return loginMapping, loginToInfo +} + +// buildVelocityTimeline creates weekly aggregated velocity data for trend visualization +func buildVelocityTimeline(data *models.RawData, period models.Period, scoringConfig config.ScoringConfig) *models.VelocityTimeline { + // Determine date range + start := period.Start + end := period.End + + // Ensure we have valid dates + if start.IsZero() { + // Default to 90 days ago + start = time.Now().AddDate(0, 0, -90) + } + if end.IsZero() { + end = time.Now() + } + + // Calculate week boundaries (start from Monday of the first week) + // Go back to the Monday of the start week + weekday := int(start.Weekday()) + if weekday == 0 { + weekday = 7 // Sunday = 7 + } + weekStart := start.AddDate(0, 0, -(weekday - 1)) + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, weekStart.Location()) + + // Build list of weeks + var weeks []time.Time + for w := weekStart; w.Before(end) || w.Equal(end); w = w.AddDate(0, 0, 7) { + weeks = append(weeks, w) + } + + if len(weeks) == 0 { + return nil + } + + // Initialize counters for each week + weekCommits := make([]float64, len(weeks)) + weekPRs := make([]float64, len(weeks)) + weekReviews := make([]float64, len(weeks)) + weekScore := make([]float64, len(weeks)) + + // Helper to find week index for a date + findWeekIndex := func(t time.Time) int { + for i := len(weeks) - 1; i >= 0; i-- { + if !t.Before(weeks[i]) { + return i + } + } + return 0 + } + + // Get scoring points from config (defaults are in PointsConfig struct) + pointsCommit := scoringConfig.Points.Commit + pointsPROpened := scoringConfig.Points.PROpened + pointsPRMerged := scoringConfig.Points.PRMerged + pointsReview := scoringConfig.Points.PRReviewed + + // Use defaults if zero + if pointsCommit == 0 { + pointsCommit = 10 + } + if pointsPROpened == 0 { + pointsPROpened = 25 + } + if pointsPRMerged == 0 { + pointsPRMerged = 50 + } + if pointsReview == 0 { + pointsReview = 30 + } + + // Aggregate commits by week + for _, commit := range data.Commits { + if commit.Date.Before(start) || commit.Date.After(end) { + continue + } + idx := findWeekIndex(commit.Date) + if idx >= 0 && idx < len(weeks) { + weekCommits[idx]++ + weekScore[idx] += float64(pointsCommit) + } + } + + // Aggregate PRs by week (use merged date if available, otherwise created date) + for _, pr := range data.PullRequests { + prDate := pr.CreatedAt + if pr.MergedAt != nil { + prDate = *pr.MergedAt + } + if prDate.Before(start) || prDate.After(end) { + continue + } + idx := findWeekIndex(prDate) + if idx >= 0 && idx < len(weeks) { + weekPRs[idx]++ + if pr.IsMerged() { + weekScore[idx] += float64(pointsPRMerged) + } else { + weekScore[idx] += float64(pointsPROpened) + } + } + } + + // Aggregate reviews by week + for _, review := range data.Reviews { + if review.SubmittedAt.Before(start) || review.SubmittedAt.After(end) { + continue + } + idx := findWeekIndex(review.SubmittedAt) + if idx >= 0 && idx < len(weeks) { + weekReviews[idx]++ + weekScore[idx] += float64(pointsReview) + } + } + + // Build labels (format: "Jan 2") + labels := make([]string, len(weeks)) + for i, w := range weeks { + labels[i] = w.Format("Jan 2") + } + + return &models.VelocityTimeline{ + Labels: labels, + Series: []models.VelocityTimelineSeries{ + {Name: "Commits", Color: "#10b981", Data: weekCommits}, + {Name: "PRs", Color: "#3b82f6", Data: weekPRs}, + {Name: "Reviews", Color: "#8b5cf6", Data: weekReviews}, + {Name: "Score", Color: "#f59e0b", Data: weekScore}, + }, + } +} + +// calculateStreaks calculates the longest and current streak of consecutive days +func calculateStreaks(days map[string]bool) (longest, current int) { + if len(days) == 0 { + return 0, 0 + } + + // Convert to sorted slice of dates + dates := make([]time.Time, 0, len(days)) + for dateStr := range days { + t, err := time.Parse("2006-01-02", dateStr) + if err == nil { + dates = append(dates, t) + } + } + + if len(dates) == 0 { + return 0, 0 + } + + // Sort dates + sort.Slice(dates, func(i, j int) bool { + return dates[i].Before(dates[j]) + }) + + // Calculate streaks + longest = 1 + current = 1 + streak := 1 + + for i := 1; i < len(dates); i++ { + diff := dates[i].Sub(dates[i-1]).Hours() / 24 + if diff == 1 { + streak++ + if streak > longest { + longest = streak + } + } else { + streak = 1 + } + } + + // Check if current streak is still active (last activity was today or yesterday) + today := time.Now().Truncate(24 * time.Hour) + lastActive := dates[len(dates)-1] + daysSinceLastActive := today.Sub(lastActive).Hours() / 24 + + if daysSinceLastActive <= 1 { + current = streak + } else { + current = 0 + } + + return longest, current +} diff --git a/internal/aggregator/aggregator_test.go b/internal/aggregator/aggregator_test.go new file mode 100644 index 0000000..6bd81ba --- /dev/null +++ b/internal/aggregator/aggregator_test.go @@ -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) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..1c23562 --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..04b60aa --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..279da1d --- /dev/null +++ b/internal/config/config_test.go @@ -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") +} diff --git a/internal/config/schema.go b/internal/config/schema.go new file mode 100644 index 0000000..f9e0ba6 --- /dev/null +++ b/internal/config/schema.go @@ -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}, + }, + } +} diff --git a/internal/config/validation.go b/internal/config/validation.go new file mode 100644 index 0000000..489e1de --- /dev/null +++ b/internal/config/validation.go @@ -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 +} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go new file mode 100644 index 0000000..cc7026a --- /dev/null +++ b/internal/config/validation_test.go @@ -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()) + }) + } +} diff --git a/internal/domain/models/author.go b/internal/domain/models/author.go new file mode 100644 index 0000000..c51d514 --- /dev/null +++ b/internal/domain/models/author.go @@ -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" +} diff --git a/internal/domain/models/commit.go b/internal/domain/models/commit.go new file mode 100644 index 0000000..da35f87 --- /dev/null +++ b/internal/domain/models/commit.go @@ -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 +} diff --git a/internal/domain/models/issue.go b/internal/domain/models/issue.go new file mode 100644 index 0000000..06caa6e --- /dev/null +++ b/internal/domain/models/issue.go @@ -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"` +} diff --git a/internal/domain/models/metrics.go b/internal/domain/models/metrics.go new file mode 100644 index 0000000..073decd --- /dev/null +++ b/internal/domain/models/metrics.go @@ -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) +} diff --git a/internal/domain/models/models_test.go b/internal/domain/models/models_test.go new file mode 100644 index 0000000..abe7822 --- /dev/null +++ b/internal/domain/models/models_test.go @@ -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()) +} diff --git a/internal/domain/models/pullrequest.go b/internal/domain/models/pullrequest.go new file mode 100644 index 0000000..99f7349 --- /dev/null +++ b/internal/domain/models/pullrequest.go @@ -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 + } +} diff --git a/internal/domain/models/rawdata.go b/internal/domain/models/rawdata.go new file mode 100644 index 0000000..ec99bf9 --- /dev/null +++ b/internal/domain/models/rawdata.go @@ -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 +} diff --git a/internal/domain/models/review.go b/internal/domain/models/review.go new file mode 100644 index 0000000..9cdf83a --- /dev/null +++ b/internal/domain/models/review.go @@ -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"` +} diff --git a/internal/domain/scoring/calculator.go b/internal/domain/scoring/calculator.go new file mode 100644 index 0000000..4fb7588 --- /dev/null +++ b/internal/domain/scoring/calculator.go @@ -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 +} diff --git a/internal/domain/scoring/calculator_test.go b/internal/domain/scoring/calculator_test.go new file mode 100644 index 0000000..03509d5 --- /dev/null +++ b/internal/domain/scoring/calculator_test.go @@ -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")) +} diff --git a/internal/generator/site/generator.go b/internal/generator/site/generator.go new file mode 100644 index 0000000..025f882 --- /dev/null +++ b/internal/generator/site/generator.go @@ -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 +} diff --git a/internal/generator/site/generator_test.go b/internal/generator/site/generator_test.go new file mode 100644 index 0000000..763a648 --- /dev/null +++ b/internal/generator/site/generator_test.go @@ -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) + } +} diff --git a/internal/git/repository.go b/internal/git/repository.go new file mode 100644 index 0000000..706092b --- /dev/null +++ b/internal/git/repository.go @@ -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 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) +} diff --git a/internal/github/cache/cache.go b/internal/github/cache/cache.go new file mode 100644 index 0000000..eb14229 --- /dev/null +++ b/internal/github/cache/cache.go @@ -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{}{}) +} diff --git a/internal/github/cache/cache_test.go b/internal/github/cache/cache_test.go new file mode 100644 index 0000000..d9366ee --- /dev/null +++ b/internal/github/cache/cache_test.go @@ -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) +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..34b7fd8 --- /dev/null +++ b/internal/github/client.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..54db762 --- /dev/null +++ b/internal/server/server.go @@ -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) + }) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..863ff24 --- /dev/null +++ b/internal/server/server_test.go @@ -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("Test Page"), 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") +} diff --git a/pkg/version/version.go b/pkg/version/version.go new file mode 100644 index 0000000..c728826 --- /dev/null +++ b/pkg/version/version.go @@ -0,0 +1,8 @@ +package version + +// Version information set at build time +var ( + Version = "dev" + Commit = "unknown" + BuildDate = "unknown" +) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d370ab0 --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + Git Velocity + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..1ace2db --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2303 @@ +{ + "name": "git-velocity-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "git-velocity-dashboard", + "version": "1.0.0", + "dependencies": { + "chart.js": "^4.4.1", + "vue": "^3.4.0", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", + "integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001760", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", + "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz", + "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz", + "integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..08b06ca --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..9eca078 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,49 @@ + + + diff --git a/web/src/components/AchievementBadge.vue b/web/src/components/AchievementBadge.vue new file mode 100644 index 0000000..288481d --- /dev/null +++ b/web/src/components/AchievementBadge.vue @@ -0,0 +1,179 @@ + + + diff --git a/web/src/components/AchievementProgress.vue b/web/src/components/AchievementProgress.vue new file mode 100644 index 0000000..087d78a --- /dev/null +++ b/web/src/components/AchievementProgress.vue @@ -0,0 +1,335 @@ + + + diff --git a/web/src/components/Avatar.vue b/web/src/components/Avatar.vue new file mode 100644 index 0000000..3fed824 --- /dev/null +++ b/web/src/components/Avatar.vue @@ -0,0 +1,37 @@ + + + diff --git a/web/src/components/Breadcrumb.vue b/web/src/components/Breadcrumb.vue new file mode 100644 index 0000000..735c33f --- /dev/null +++ b/web/src/components/Breadcrumb.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/components/ContributorCard.vue b/web/src/components/ContributorCard.vue new file mode 100644 index 0000000..95a183f --- /dev/null +++ b/web/src/components/ContributorCard.vue @@ -0,0 +1,78 @@ + + + diff --git a/web/src/components/ContributorRow.vue b/web/src/components/ContributorRow.vue new file mode 100644 index 0000000..47b5b60 --- /dev/null +++ b/web/src/components/ContributorRow.vue @@ -0,0 +1,54 @@ + + + diff --git a/web/src/components/DataTable.vue b/web/src/components/DataTable.vue new file mode 100644 index 0000000..48eb014 --- /dev/null +++ b/web/src/components/DataTable.vue @@ -0,0 +1,85 @@ + + + diff --git a/web/src/components/ErrorState.vue b/web/src/components/ErrorState.vue new file mode 100644 index 0000000..d3c54f8 --- /dev/null +++ b/web/src/components/ErrorState.vue @@ -0,0 +1,22 @@ + + + diff --git a/web/src/components/Footer.vue b/web/src/components/Footer.vue new file mode 100644 index 0000000..d04397e --- /dev/null +++ b/web/src/components/Footer.vue @@ -0,0 +1,35 @@ + + + diff --git a/web/src/components/GithubLink.vue b/web/src/components/GithubLink.vue new file mode 100644 index 0000000..c3bf262 --- /dev/null +++ b/web/src/components/GithubLink.vue @@ -0,0 +1,29 @@ + + + diff --git a/web/src/components/LoadingState.vue b/web/src/components/LoadingState.vue new file mode 100644 index 0000000..4cea79a --- /dev/null +++ b/web/src/components/LoadingState.vue @@ -0,0 +1,17 @@ + + + diff --git a/web/src/components/MemberCard.vue b/web/src/components/MemberCard.vue new file mode 100644 index 0000000..b7c0688 --- /dev/null +++ b/web/src/components/MemberCard.vue @@ -0,0 +1,79 @@ + + + diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue new file mode 100644 index 0000000..95cfb42 --- /dev/null +++ b/web/src/components/Navbar.vue @@ -0,0 +1,90 @@ + + + diff --git a/web/src/components/PageHeader.vue b/web/src/components/PageHeader.vue new file mode 100644 index 0000000..02b4b0a --- /dev/null +++ b/web/src/components/PageHeader.vue @@ -0,0 +1,52 @@ + + + diff --git a/web/src/components/RankBadge.vue b/web/src/components/RankBadge.vue new file mode 100644 index 0000000..6c138ad --- /dev/null +++ b/web/src/components/RankBadge.vue @@ -0,0 +1,31 @@ + + + diff --git a/web/src/components/RepoCard.vue b/web/src/components/RepoCard.vue new file mode 100644 index 0000000..80bdab1 --- /dev/null +++ b/web/src/components/RepoCard.vue @@ -0,0 +1,47 @@ + + + diff --git a/web/src/components/SectionHeader.vue b/web/src/components/SectionHeader.vue new file mode 100644 index 0000000..ac944d8 --- /dev/null +++ b/web/src/components/SectionHeader.vue @@ -0,0 +1,23 @@ + + + diff --git a/web/src/components/StatCard.vue b/web/src/components/StatCard.vue new file mode 100644 index 0000000..0d300e0 --- /dev/null +++ b/web/src/components/StatCard.vue @@ -0,0 +1,28 @@ + + + diff --git a/web/src/components/TeamCard.vue b/web/src/components/TeamCard.vue new file mode 100644 index 0000000..a3a2454 --- /dev/null +++ b/web/src/components/TeamCard.vue @@ -0,0 +1,56 @@ + + + diff --git a/web/src/components/ThemeToggle.vue b/web/src/components/ThemeToggle.vue new file mode 100644 index 0000000..a7f048c --- /dev/null +++ b/web/src/components/ThemeToggle.vue @@ -0,0 +1,48 @@ + + + diff --git a/web/src/components/VelocityChart.vue b/web/src/components/VelocityChart.vue new file mode 100644 index 0000000..43fa436 --- /dev/null +++ b/web/src/components/VelocityChart.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/web/src/composables/formatters.js b/web/src/composables/formatters.js new file mode 100644 index 0000000..68ae506 --- /dev/null +++ b/web/src/composables/formatters.js @@ -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, '') +} diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..06fb4c3 --- /dev/null +++ b/web/src/main.js @@ -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') diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..12ec708 --- /dev/null +++ b/web/src/style.css @@ -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); + } + } +} diff --git a/web/src/views/Contributor.vue b/web/src/views/Contributor.vue new file mode 100644 index 0000000..1c87341 --- /dev/null +++ b/web/src/views/Contributor.vue @@ -0,0 +1,368 @@ + + + diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..f3ce95c --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,157 @@ + + + diff --git a/web/src/views/Leaderboard.vue b/web/src/views/Leaderboard.vue new file mode 100644 index 0000000..1dc6001 --- /dev/null +++ b/web/src/views/Leaderboard.vue @@ -0,0 +1,104 @@ + + + diff --git a/web/src/views/Repository.vue b/web/src/views/Repository.vue new file mode 100644 index 0000000..3893272 --- /dev/null +++ b/web/src/views/Repository.vue @@ -0,0 +1,143 @@ + + + diff --git a/web/src/views/Team.vue b/web/src/views/Team.vue new file mode 100644 index 0000000..866bbd5 --- /dev/null +++ b/web/src/views/Team.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/tailwind.config.js b/web/tailwind.config.js new file mode 100644 index 0000000..854cc06 --- /dev/null +++ b/web/tailwind.config.js @@ -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: [], +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..eec5c1b --- /dev/null +++ b/web/vite.config.js @@ -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'] + } + } + } + } +})