diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..301b4ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Git +.git +.gitignore + +# IDE +.idea +.vscode +*.swp +*.swo + +# Environment files (secrets) +.env +*.pem + +# Build artifacts +gitvigil +*.exe +*.dll +*.so +*.dylib + +# Test files +*_test.go +coverage.out +coverage.html + +# Documentation +*.md +!README.md +docs/ + +# Docker files (not needed in context) +docker-compose*.yml +Dockerfile* + +# Misc +.DS_Store +Thumbs.db +tmp/ diff --git a/.env b/.env new file mode 100644 index 0000000..e1d6049 --- /dev/null +++ b/.env @@ -0,0 +1,17 @@ +GITHUB_APP_CLIENT_SECRET=34f253c2285ebdb408e230950eeb2da6eecad40d +GITHUB_APP_ID=2765088 +GITHUB_APP_CLIENT_ID=Iv23li9lM5FdeHqeNZ6o +GITHUB_WEBHOOK_SECRET=my_secret_token_123 +GITHUB_PRIVATE_KEY_PATH=./git-vigil.2026-01-30.private-key.pem + +# Database +DATABASE_URL=postgres://postgres:postgres@localhost:5432/gitvigil?sslmode=disable + +# Server +PORT=8080 +BASE_URL=https://harshs-macbook-pro.ruffe-minor.ts.net + +# Detection thresholds +BACKDATE_SUSPICIOUS_HOURS=24 +BACKDATE_CRITICAL_HOURS=72 +STREAK_INACTIVITY_HOURS=72 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a7e2743 --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# GitVigil Environment Configuration +# Copy this file to .env and fill in your values + +# =================== +# GitHub App Settings +# =================== +# Get these from your GitHub App settings page +GITHUB_APP_ID=your_app_id +GITHUB_APP_CLIENT_ID=your_client_id +GITHUB_APP_CLIENT_SECRET=your_client_secret + +# Webhook secret (set in GitHub App settings) +GITHUB_WEBHOOK_SECRET=your_webhook_secret + +# Path to your GitHub App's private key PEM file +GITHUB_PRIVATE_KEY_PATH=./your-app.private-key.pem + +# =================== +# Database Settings +# =================== +# PostgreSQL connection string +# For Docker Compose: postgres://gitvigil:gitvigil@db:5432/gitvigil?sslmode=disable +# For local dev: postgres://postgres:postgres@localhost:5432/gitvigil?sslmode=disable +DATABASE_URL=postgres://gitvigil:gitvigil@localhost:5432/gitvigil?sslmode=disable + +# Database password (used by Docker Compose) +DB_PASSWORD=gitvigil + +# =================== +# Server Settings +# =================== +PORT=8080 +BASE_URL=https://your-domain.com + +# =================== +# Detection Thresholds +# =================== +# Hours after which a commit is flagged as suspicious (default: 24) +BACKDATE_SUSPICIOUS_HOURS=24 + +# Hours after which a commit is flagged as critical (default: 72) +BACKDATE_CRITICAL_HOURS=72 + +# Hours of inactivity before streak is at risk (default: 72) +STREAK_INACTIVITY_HOURS=72 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..39bdfd6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +GitVigil is a hackathon monitoring GitHub App that detects commit backdating, monitors force-pushes, validates licenses, tracks activity streaks, and provides reviewer insights. + +**Module:** `github.com/harshpatel5940/gitvigil` + +## Development Commands + +```bash +# Build +go build -o gitvigil ./cmd/main.go + +# Run (requires PostgreSQL and .env configured) +go run ./cmd/main.go + +# Test +go test ./... + +# Format +go fmt ./... + +# Vet +go vet ./... +``` + +## Architecture + +``` +cmd/main.go # HTTP server, config, graceful shutdown +internal/ +├── config/config.go # Load .env, validate settings +├── database/ +│ ├── database.go # pgx connection pool +│ ├── migrate.go # Embedded migration runner +│ └── migrations/ # SQL schema files +├── github/ +│ └── app.go # JWT auth, installation tokens +├── webhook/ +│ ├── handler.go # /webhook endpoint, push event processing +│ └── signature.go # HMAC-SHA256 verification +├── detection/ +│ └── detector.go # Backdate, license, streak detection +├── analysis/ +│ ├── commits.go # Conventional Commits parsing +│ ├── distribution.go # Contribution distribution (Gini coefficient) +│ └── volume.go # Daily builder vs deadline dumper +├── scorecard/ +│ └── handler.go # /scorecard API endpoint +├── auth/ +│ └── handler.go # /auth/github/callback OAuth +├── api/ +│ ├── handler.go # Base API with JSON helpers +│ ├── repositories.go # Repository list/get endpoints +│ ├── installations.go # Installation list/get endpoints +│ └── stats.go # System statistics endpoint +└── models/ + ├── repository.go # Repository CRUD + ├── commit.go # Commit stats + ├── alert.go # Alert types and CRUD + ├── contributor.go # Contributor stats + └── installation.go # Installation CRUD +``` + +## API Endpoints + +- `POST /webhook` - Receives GitHub push/installation events +- `GET /scorecard?repo=owner/name` - Returns repository analysis JSON +- `GET /auth/github/callback` - OAuth callback +- `GET /health` - Health check + +**Management API (v1):** +- `GET /api/v1/repositories` - List all monitored repositories +- `GET /api/v1/repositories/:id` - Get single repository +- `GET /api/v1/installations` - List all GitHub App installations +- `GET /api/v1/installations/:id` - Get installation details +- `GET /api/v1/installations/:id/repositories` - List repos for installation +- `GET /api/v1/stats` - System-wide statistics + +## Configuration + +Environment variables (`.env`): +- `GITHUB_APP_ID`, `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET` +- `GITHUB_WEBHOOK_SECRET` - For webhook signature verification +- `GITHUB_PRIVATE_KEY_PATH` - Path to RSA private key PEM +- `DATABASE_URL` - PostgreSQL connection string +- `PORT`, `BASE_URL` +- `BACKDATE_SUSPICIOUS_HOURS` (default: 24), `BACKDATE_CRITICAL_HOURS` (default: 72) +- `STREAK_INACTIVITY_HOURS` (default: 72) + +## Self-Hosting with Docker + +```bash +# Quick start +cp .env.example .env +# Edit .env with your GitHub App credentials + +# Start services +make docker-up + +# View logs +make docker-logs + +# Stop services +make docker-down +``` + +## Detection Logic + +**Backdate Detection**: Compares webhook receive time vs commit `author.date`. Flags commits with >24h difference as suspicious, >72h as critical. + +**Force Push**: Detects `forced: true` on push events. + +**Streak Tracking**: Marks repos as "at_risk" after 72 hours of inactivity. + +**Conventional Commits**: Validates `feat:`, `fix:`, `docs:`, etc. prefixes. + +**Contribution Patterns**: Identifies "deadline_dumper" (>50% commits in last 20% of time) vs "daily_builder" (consistent activity). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..465a672 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,65 @@ +# ============================================================================= +# GitVigil Dockerfile +# Multi-stage build for minimal, secure production image +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Build Stage +# ----------------------------------------------------------------------------- +FROM golang:1.23-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git ca-certificates tzdata + +# Create non-root user for runtime +RUN adduser -D -g '' appuser + +WORKDIR /app + +# Download dependencies first (better caching) +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +# - CGO_ENABLED=0: Static binary, no C dependencies +# - -ldflags="-s -w": Strip debug info for smaller binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w" \ + -o /gitvigil \ + ./cmd/main.go + +# ----------------------------------------------------------------------------- +# Runtime Stage +# ----------------------------------------------------------------------------- +FROM alpine:3.19 + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Import user from builder +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /gitvigil . + +# Copy migrations (embedded in binary, but keep for reference) +COPY --from=builder /app/internal/database/migrations ./internal/database/migrations + +# Use non-root user +USER appuser + +# Expose default port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +# Run the application +ENTRYPOINT ["/app/gitvigil"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..79bd1db --- /dev/null +++ b/Makefile @@ -0,0 +1,147 @@ +# ============================================================================= +# GitVigil Makefile +# ============================================================================= + +.PHONY: all build run test fmt vet clean dev \ + docker-build docker-up docker-down docker-logs docker-clean \ + help + +# Go parameters +BINARY_NAME := gitvigil +MAIN_PATH := ./cmd/main.go +GO := go + +# Docker parameters +DOCKER_IMAGE := gitvigil +DOCKER_TAG := latest + +# ============================================================================= +# Development +# ============================================================================= + +## build: Build the binary +build: + @echo "Building $(BINARY_NAME)..." + @$(GO) build -o $(BINARY_NAME) $(MAIN_PATH) + @echo "Done!" + +## run: Run the application locally +run: + @$(GO) run $(MAIN_PATH) + +## test: Run all tests +test: + @echo "Running tests..." + @$(GO) test -v ./... + +## test-coverage: Run tests with coverage +test-coverage: + @echo "Running tests with coverage..." + @$(GO) test -coverprofile=coverage.out ./... + @$(GO) tool cover -html=coverage.out -o coverage.html + @echo "Coverage report: coverage.html" + +## fmt: Format code +fmt: + @echo "Formatting code..." + @$(GO) fmt ./... + +## vet: Run go vet +vet: + @echo "Running go vet..." + @$(GO) vet ./... + +## lint: Run linter (requires golangci-lint) +lint: + @echo "Running linter..." + @golangci-lint run ./... + +## clean: Clean build artifacts +clean: + @echo "Cleaning..." + @rm -f $(BINARY_NAME) + @rm -f coverage.out coverage.html + @$(GO) clean + @echo "Done!" + +## dev: Format, vet, and build +dev: fmt vet build + +## tidy: Tidy go modules +tidy: + @$(GO) mod tidy + +# ============================================================================= +# Docker +# ============================================================================= + +## docker-build: Build Docker image +docker-build: + @echo "Building Docker image..." + @docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) . + @echo "Done!" + +## docker-up: Start services with Docker Compose +docker-up: + @echo "Starting services..." + @docker compose up -d + @echo "Services started. Access at http://localhost:8080" + +## docker-down: Stop Docker Compose services +docker-down: + @echo "Stopping services..." + @docker compose down + +## docker-logs: View application logs +docker-logs: + @docker compose logs -f app + +## docker-logs-db: View database logs +docker-logs-db: + @docker compose logs -f db + +## docker-restart: Restart the application +docker-restart: + @docker compose restart app + +## docker-clean: Stop services and remove volumes +docker-clean: + @echo "Stopping services and removing volumes..." + @docker compose down -v + @echo "Done!" + +## docker-shell: Open shell in app container +docker-shell: + @docker compose exec app sh + +## docker-db-shell: Open psql in database container +docker-db-shell: + @docker compose exec db psql -U gitvigil -d gitvigil + +# ============================================================================= +# Database +# ============================================================================= + +## db-migrate: Run database migrations (local) +db-migrate: + @echo "Running migrations..." + @$(GO) run $(MAIN_PATH) migrate + +# ============================================================================= +# Help +# ============================================================================= + +## help: Show this help message +help: + @echo "GitVigil - Hackathon Monitoring Service" + @echo "" + @echo "Usage: make [target]" + @echo "" + @echo "Development:" + @sed -n 's/^## //p' $(MAKEFILE_LIST) | grep -E '^[a-z]' | grep -v 'docker-' | column -t -s ':' + @echo "" + @echo "Docker:" + @sed -n 's/^## //p' $(MAKEFILE_LIST) | grep -E '^docker-' | column -t -s ':' + +# Default target +all: dev diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..f548b51 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/harshpatel5940/gitvigil/internal/config" + "github.com/harshpatel5940/gitvigil/internal/database" + "github.com/harshpatel5940/gitvigil/internal/github" + "github.com/harshpatel5940/gitvigil/internal/server" + "github.com/rs/zerolog" +) + +func main() { + // Setup logger + logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr}). + With(). + Timestamp(). + Caller(). + Logger() + + // Load configuration + cfg, err := config.Load() + if err != nil { + logger.Fatal().Err(err).Msg("failed to load configuration") + } + + logger.Info(). + Int64("app_id", cfg.AppID). + Str("port", cfg.Port). + Msg("configuration loaded") + + // Setup context with signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + logger.Info().Str("signal", sig.String()).Msg("received shutdown signal") + cancel() + }() + + // Connect to database + db, err := database.New(ctx, cfg.DatabaseURL) + if err != nil { + logger.Fatal().Err(err).Msg("failed to connect to database") + } + defer db.Close() + logger.Info().Msg("connected to database") + + // Run migrations + if err := database.RunMigrations(cfg.DatabaseURL); err != nil { + logger.Fatal().Err(err).Msg("failed to run migrations") + } + logger.Info().Msg("migrations completed") + + // Create GitHub App client + gh, err := github.NewAppClient(cfg.AppID, cfg.PrivateKey) + if err != nil { + logger.Fatal().Err(err).Msg("failed to create GitHub App client") + } + logger.Info().Int64("app_id", gh.AppID()).Msg("GitHub App client created") + + // Create and start server + srv := server.New(cfg, db, gh, logger) + + if err := srv.Start(ctx); err != nil { + logger.Fatal().Err(err).Msg("server error") + } + + logger.Info().Msg("server stopped gracefully") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a1f001f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,85 @@ +# ============================================================================= +# GitVigil Docker Compose +# Self-hosted deployment with PostgreSQL +# ============================================================================= +# Usage: +# 1. Copy .env.example to .env and configure +# 2. docker compose up -d +# 3. Access at http://localhost:8080 +# ============================================================================= + +services: + # --------------------------------------------------------------------------- + # PostgreSQL Database + # --------------------------------------------------------------------------- + db: + image: postgres:16-alpine + container_name: gitvigil-db + restart: unless-stopped + environment: + POSTGRES_USER: gitvigil + POSTGRES_PASSWORD: ${DB_PASSWORD:-gitvigil} + POSTGRES_DB: gitvigil + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitvigil -d gitvigil"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - gitvigil-network + + # --------------------------------------------------------------------------- + # GitVigil Application + # --------------------------------------------------------------------------- + app: + build: + context: . + dockerfile: Dockerfile + container_name: gitvigil-app + restart: unless-stopped + ports: + - "${PORT:-8080}:8080" + environment: + # Database + DATABASE_URL: postgres://gitvigil:${DB_PASSWORD:-gitvigil}@db:5432/gitvigil?sslmode=disable + + # Server + PORT: "8080" + BASE_URL: ${BASE_URL:-http://localhost:8080} + + # GitHub App (from host .env) + GITHUB_APP_ID: ${GITHUB_APP_ID} + GITHUB_APP_CLIENT_ID: ${GITHUB_APP_CLIENT_ID} + GITHUB_APP_CLIENT_SECRET: ${GITHUB_APP_CLIENT_SECRET} + GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET} + GITHUB_PRIVATE_KEY_PATH: /app/private-key.pem + + # Detection thresholds + BACKDATE_SUSPICIOUS_HOURS: ${BACKDATE_SUSPICIOUS_HOURS:-24} + BACKDATE_CRITICAL_HOURS: ${BACKDATE_CRITICAL_HOURS:-72} + STREAK_INACTIVITY_HOURS: ${STREAK_INACTIVITY_HOURS:-72} + volumes: + # Mount private key as read-only + - ${GITHUB_PRIVATE_KEY_PATH:-./private-key.pem}:/app/private-key.pem:ro + depends_on: + db: + condition: service_healthy + networks: + - gitvigil-network + +# ============================================================================= +# Networks +# ============================================================================= +networks: + gitvigil-network: + driver: bridge + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + postgres_data: + driver: local diff --git a/git-vigil.2026-01-30.private-key.pem b/git-vigil.2026-01-30.private-key.pem new file mode 100644 index 0000000..9c0ea3a --- /dev/null +++ b/git-vigil.2026-01-30.private-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEAwUd/K8rEkjfPX6IfwT2ySscgfISb4PRGc87GDMJ1XBlTOSII +vBipooUgWSG7nfdM+409Xh7kyRqsVqTc9JHxwVmdevQCJxBJ+KDoq/8BqQnknypL +Hpkp3hSJfoZikYgWD5pf0JQjhpsw2BD7ZbOOtkUJ4DuS/Rl2/pVRXCwi41JrhvZm +1qpLSN6SQW5G3aB3LSXqmrVLPct3BBZBRYMCCcVKOdyRSZ8iSjEmCwAqZZV/5ULl +rtMb5d/fNQvRH6HlUJ6VMhakz1gQEkw/hC4CgMZ55fRuUORyLJBmVkg7wTN9Lawu +ZwnkfvJQ0nIkCs5yjC/Sa4GaeHOgjix2P/kkhwIDAQABAoIBACOHUAItyMqUBcOv +mqS8AZ0rU3ZwLfNBE/5PwSoxSL+ATEMRVYe2BODCFfssbz+PaRBIm0JE7YtmIs3e +iltOxKDlJ0SlvAuQO/i/I2YSfyyii+sSLDLYttCC9+9RJqdX265Mk1ZVFN62glkF +biD4y8AHmtQkP1XVK4GK0yTdXCBrAV9WmHCgDEjvSTDBerJ8FO5/a36hr+6WHo/7 +1TlDR/WVAphzQ38YmvcTAnU8VDx+k/GxwO9Bq6mS5Wlgzws0+UxAixtmNO+nCuNa +jZ0tDNzx0sKdz1/QbQKnvQgH2oqBxk0EYa4TLYmVF04RvrfU/Dyeo+gh7zhIEutV +sVzmjKECgYEA36xIDCMr82vDPkm4JPw4og+vZMNiZwjUjuxxgSyICU8j0hRDtJVp +uzm33kWUSsKiSL8DIexGvFUf29WWT9FS+5z9HquuRkF7ILPFpysUqKozhwzzhMVb +GVXR8apVVzpA0vsVfkvBOa73O4Ejl5zIHjkMDga2/40b1R+hntM8JxcCgYEA3Tas +Z2zeewhx4IPSBivOkWDyIoRcCcriA7yOpVd3V/8DpBqB7zSDuqtIAuT2CB8pLm5b +a0DIR3D9kzWBsZX2ljSJhxnwxEKYehpNtZKb47V+iJZ6ht4Y8A2INf0ga2TVtHyx +ycnK8Ei7VdnLkUM2ShcyHvzcTItZmL3bIX1xVBECgYB+7F9R699z2THItcJ10l48 +sFPiBPFg8GTV2ZwrJd7oEW2NN4yX+7Us1frdeXRF3B6E00dux4n4MwHKGHCcSHcj +aplD/z38hgndq8W8L7kgYLyupQ3GyMsCHG3vCa41ukuwrWQr78bs+bk3nfdZgFEa +vVp21x6e8y7ZCIMJAmr+7wKBgC6UnKkKm658kL4eA9OY1d5284WhKWBGgGEZC1B1 +ooO9Bx8/Fa0w+awM9RTm8Ye0SA+m3UkwUvv4Ju0gjza9xTbLyUnRhIlPHJxZfZOG +U50XUpWgWhycF9Q0hUIZEwKKMRXYS/eZrkn6hrgFLIBiDsR7LeqohBr6HNu03rem +mn6BAoGAA49MggUe7WOySkEU4NtaK6jQT5jKhWEu4WStpNU36nDqTrg+5zjVt7tX +XnlP11FEW+zFcjQCws+m23MckCCBUQ00ifsPjk6e9BAl0jXDXuwPOYsrvwQPivvp +Fjzv3kA1m6hP4fwnUZ71OYUkG/QxJ29BIf/peLAexopSdyWUvhY= +-----END RSA PRIVATE KEY----- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4d4beb --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module github.com/harshpatel5940/gitvigil + +go 1.24.0 + +require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 + github.com/go-chi/chi/v5 v5.2.2 + github.com/golang-migrate/migrate/v4 v4.18.2 + github.com/google/go-github/v68 v68.0.0 + github.com/jackc/pgx/v5 v5.7.2 + github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.33.0 +) + +require ( + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-github/v69 v69.0.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + go.uber.org/atomic v1.7.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2d74259 --- /dev/null +++ b/go.sum @@ -0,0 +1,114 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/bradleyfalzon/ghinstallation/v2 v2.14.0 h1:0D4vKCHOvYrDU8u61TnE2JfNT4VRrBLphmxtqazTO+M= +github.com/bradleyfalzon/ghinstallation/v2 v2.14.0/go.mod h1:LOVmdZYVZ8jqdr4n9wWm1ocDiMz9IfMGfRkaYC1a52A= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= +github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= +github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= +github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/v69 v69.0.0 h1:YnFvZ3pEIZF8KHmI8xyQQe3mYACdkhnaTV2hr7CP2/w= +github.com/google/go-github/v69 v69.0.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +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/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/analysis/commits.go b/internal/analysis/commits.go new file mode 100644 index 0000000..124e57f --- /dev/null +++ b/internal/analysis/commits.go @@ -0,0 +1,104 @@ +package analysis + +import ( + "regexp" + "strings" +) + +// ConventionalCommit represents a parsed conventional commit +type ConventionalCommit struct { + Type string + Scope string + Description string + IsBreaking bool + IsValid bool +} + +var conventionalCommitRegex = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)`) + +var validTypes = map[string]bool{ + "feat": true, + "fix": true, + "docs": true, + "style": true, + "refactor": true, + "perf": true, + "test": true, + "build": true, + "ci": true, + "chore": true, + "revert": true, +} + +// ParseConventionalCommit parses a commit message according to the Conventional Commits spec +func ParseConventionalCommit(message string) *ConventionalCommit { + if message == "" { + return &ConventionalCommit{IsValid: false} + } + + // Get first line + firstLine := strings.Split(message, "\n")[0] + + matches := conventionalCommitRegex.FindStringSubmatch(firstLine) + if matches == nil { + return &ConventionalCommit{IsValid: false} + } + + commitType := strings.ToLower(matches[1]) + if !validTypes[commitType] { + return &ConventionalCommit{IsValid: false} + } + + return &ConventionalCommit{ + Type: commitType, + Scope: matches[2], + IsBreaking: matches[3] == "!", + Description: matches[4], + IsValid: true, + } +} + +// CommitQualityAnalysis contains the analysis of commit quality for a repository +type CommitQualityAnalysis struct { + TotalCommits int `json:"total_commits"` + ConventionalCount int `json:"conventional_count"` + ConventionalPct float64 `json:"conventional_pct"` + TypeDistribution map[string]int `json:"type_distribution"` + BreakingChanges int `json:"breaking_changes"` + AverageMessageLen float64 `json:"average_message_length"` + CommitsWithScope int `json:"commits_with_scope"` +} + +// AnalyzeCommitQuality analyzes the quality of commits +func AnalyzeCommitQuality(messages []string) *CommitQualityAnalysis { + analysis := &CommitQualityAnalysis{ + TotalCommits: len(messages), + TypeDistribution: make(map[string]int), + } + + if len(messages) == 0 { + return analysis + } + + totalLen := 0 + for _, msg := range messages { + totalLen += len(msg) + + cc := ParseConventionalCommit(msg) + if cc.IsValid { + analysis.ConventionalCount++ + analysis.TypeDistribution[cc.Type]++ + if cc.IsBreaking { + analysis.BreakingChanges++ + } + if cc.Scope != "" { + analysis.CommitsWithScope++ + } + } + } + + analysis.ConventionalPct = float64(analysis.ConventionalCount) / float64(analysis.TotalCommits) * 100 + analysis.AverageMessageLen = float64(totalLen) / float64(analysis.TotalCommits) + + return analysis +} diff --git a/internal/analysis/distribution.go b/internal/analysis/distribution.go new file mode 100644 index 0000000..4e6348a --- /dev/null +++ b/internal/analysis/distribution.go @@ -0,0 +1,185 @@ +package analysis + +import ( + "math" + "sort" +) + +// ContributorData represents a contributor's activity data +type ContributorData struct { + Login string + Commits int + Additions int64 + Deletions int64 +} + +// DistributionAnalysis contains the analysis of contribution distribution +type DistributionAnalysis struct { + TotalContributors int `json:"total_contributors"` + TotalCommits int `json:"total_commits"` + GiniCoefficient float64 `json:"gini_coefficient"` + TopContributor *ContributorShare `json:"top_contributor"` + Contributors []ContributorShare `json:"contributors"` + Pattern string `json:"pattern"` + PatternDesc string `json:"pattern_description"` +} + +// ContributorShare represents a contributor's share of the work +type ContributorShare struct { + Login string `json:"login"` + Commits int `json:"commits"` + CommitShare float64 `json:"commit_share"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + CodeShare float64 `json:"code_share"` +} + +// AnalyzeDistribution analyzes the distribution of contributions +func AnalyzeDistribution(contributors []ContributorData) *DistributionAnalysis { + analysis := &DistributionAnalysis{ + TotalContributors: len(contributors), + Contributors: make([]ContributorShare, 0, len(contributors)), + } + + if len(contributors) == 0 { + analysis.Pattern = "no_activity" + analysis.PatternDesc = "No contributions found" + return analysis + } + + // Calculate totals + var totalCommits int + var totalLines int64 + for _, c := range contributors { + totalCommits += c.Commits + totalLines += c.Additions + c.Deletions + } + analysis.TotalCommits = totalCommits + + // Calculate shares + commitShares := make([]float64, len(contributors)) + for i, c := range contributors { + var commitShare, codeShare float64 + if totalCommits > 0 { + commitShare = float64(c.Commits) / float64(totalCommits) * 100 + } + if totalLines > 0 { + codeShare = float64(c.Additions+c.Deletions) / float64(totalLines) * 100 + } + + commitShares[i] = commitShare + + share := ContributorShare{ + Login: c.Login, + Commits: c.Commits, + CommitShare: commitShare, + Additions: c.Additions, + Deletions: c.Deletions, + CodeShare: codeShare, + } + analysis.Contributors = append(analysis.Contributors, share) + } + + // Sort by commits descending + sort.Slice(analysis.Contributors, func(i, j int) bool { + return analysis.Contributors[i].Commits > analysis.Contributors[j].Commits + }) + + if len(analysis.Contributors) > 0 { + analysis.TopContributor = &analysis.Contributors[0] + } + + // Calculate Gini coefficient for commit distribution + analysis.GiniCoefficient = calculateGini(commitShares) + + // Determine pattern + analysis.determinePattern() + + return analysis +} + +func (a *DistributionAnalysis) determinePattern() { + if a.TotalContributors == 0 { + a.Pattern = "no_activity" + a.PatternDesc = "No contributions found" + return + } + + if a.TotalContributors == 1 { + a.Pattern = "solo" + a.PatternDesc = "Single contributor project" + return + } + + if a.TopContributor != nil && a.TopContributor.CommitShare > 80 { + a.Pattern = "lone_wolf" + a.PatternDesc = "Dominated by a single contributor (>80% of commits)" + return + } + + if a.GiniCoefficient < 0.3 { + a.Pattern = "balanced" + a.PatternDesc = "Well-balanced contribution distribution" + return + } + + if a.GiniCoefficient < 0.5 { + a.Pattern = "moderate_imbalance" + a.PatternDesc = "Moderately imbalanced distribution" + return + } + + a.Pattern = "imbalanced" + a.PatternDesc = "Highly imbalanced contribution distribution" +} + +// calculateGini calculates the Gini coefficient for a distribution +// 0 = perfect equality, 1 = perfect inequality +func calculateGini(values []float64) float64 { + n := len(values) + if n == 0 { + return 0 + } + + // Sort values + sorted := make([]float64, n) + copy(sorted, values) + sort.Float64s(sorted) + + // Calculate Gini + var sum, cumSum float64 + for i, v := range sorted { + cumSum += v + sum += float64(i+1) * v + } + + if cumSum == 0 { + return 0 + } + + return (2*sum)/(float64(n)*cumSum) - (float64(n)+1)/float64(n) +} + +// CalculateStandardDeviation calculates the standard deviation of commit counts +func CalculateStandardDeviation(commits []int) float64 { + if len(commits) == 0 { + return 0 + } + + // Calculate mean + var sum float64 + for _, c := range commits { + sum += float64(c) + } + mean := sum / float64(len(commits)) + + // Calculate variance + var variance float64 + for _, c := range commits { + diff := float64(c) - mean + variance += diff * diff + } + variance /= float64(len(commits)) + + return math.Sqrt(variance) +} diff --git a/internal/analysis/volume.go b/internal/analysis/volume.go new file mode 100644 index 0000000..7d6755f --- /dev/null +++ b/internal/analysis/volume.go @@ -0,0 +1,255 @@ +package analysis + +import ( + "sort" + "time" +) + +// DailyActivity represents activity on a single day +type DailyActivity struct { + Date time.Time + Commits int + Additions int + Deletions int +} + +// VolumeAnalysis contains the analysis of code volume patterns +type VolumeAnalysis struct { + TotalDays int `json:"total_days"` + ActiveDays int `json:"active_days"` + TotalCommits int `json:"total_commits"` + TotalAdditions int `json:"total_additions"` + TotalDeletions int `json:"total_deletions"` + AveragePerDay float64 `json:"average_commits_per_day"` + MaxDailyCommits int `json:"max_daily_commits"` + Pattern string `json:"pattern"` + PatternDesc string `json:"pattern_description"` + ConsistencyScore float64 `json:"consistency_score"` + LastDayPercentage float64 `json:"last_day_percentage"` + DailyBreakdown []DayBreakdown `json:"daily_breakdown,omitempty"` +} + +// DayBreakdown represents a single day's activity +type DayBreakdown struct { + Date string `json:"date"` + Commits int `json:"commits"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Pct float64 `json:"percentage"` +} + +// AnalyzeVolume analyzes the volume and timing patterns of contributions +func AnalyzeVolume(activities []DailyActivity, hackathonStart, hackathonEnd time.Time) *VolumeAnalysis { + analysis := &VolumeAnalysis{ + DailyBreakdown: make([]DayBreakdown, 0), + } + + if len(activities) == 0 { + analysis.Pattern = "no_activity" + analysis.PatternDesc = "No activity recorded" + return analysis + } + + // Sort by date + sort.Slice(activities, func(i, j int) bool { + return activities[i].Date.Before(activities[j].Date) + }) + + // Calculate totals + for _, a := range activities { + if a.Commits > 0 { + analysis.ActiveDays++ + } + analysis.TotalCommits += a.Commits + analysis.TotalAdditions += a.Additions + analysis.TotalDeletions += a.Deletions + + if a.Commits > analysis.MaxDailyCommits { + analysis.MaxDailyCommits = a.Commits + } + } + + // Calculate total days in period + if !hackathonStart.IsZero() && !hackathonEnd.IsZero() { + analysis.TotalDays = int(hackathonEnd.Sub(hackathonStart).Hours()/24) + 1 + } else if len(activities) > 0 { + analysis.TotalDays = int(activities[len(activities)-1].Date.Sub(activities[0].Date).Hours()/24) + 1 + } + + // Calculate average + if analysis.TotalDays > 0 { + analysis.AveragePerDay = float64(analysis.TotalCommits) / float64(analysis.TotalDays) + } + + // Calculate daily breakdown and percentages + for _, a := range activities { + var pct float64 + if analysis.TotalCommits > 0 { + pct = float64(a.Commits) / float64(analysis.TotalCommits) * 100 + } + + analysis.DailyBreakdown = append(analysis.DailyBreakdown, DayBreakdown{ + Date: a.Date.Format("2006-01-02"), + Commits: a.Commits, + Additions: a.Additions, + Deletions: a.Deletions, + Pct: pct, + }) + } + + // Calculate last day percentage + if len(activities) > 0 && analysis.TotalCommits > 0 { + lastDay := activities[len(activities)-1] + analysis.LastDayPercentage = float64(lastDay.Commits) / float64(analysis.TotalCommits) * 100 + } + + // Calculate consistency score + analysis.ConsistencyScore = calculateConsistencyScore(activities, analysis.TotalDays) + + // Determine pattern + analysis.determinePattern() + + return analysis +} + +func (a *VolumeAnalysis) determinePattern() { + if a.TotalCommits == 0 { + a.Pattern = "no_activity" + a.PatternDesc = "No activity recorded" + return + } + + // Check for deadline dumper: >50% of commits in last 20% of time + lastPeriodPct := 20.0 + if a.TotalDays > 0 { + lastDays := int(float64(a.TotalDays) * lastPeriodPct / 100) + if lastDays < 1 { + lastDays = 1 + } + + // Count commits in last period + lastPeriodCommits := 0 + if len(a.DailyBreakdown) > 0 { + startIdx := len(a.DailyBreakdown) - lastDays + if startIdx < 0 { + startIdx = 0 + } + for i := startIdx; i < len(a.DailyBreakdown); i++ { + lastPeriodCommits += a.DailyBreakdown[i].Commits + } + } + + lastPeriodPctActual := float64(lastPeriodCommits) / float64(a.TotalCommits) * 100 + if lastPeriodPctActual > 50 { + a.Pattern = "deadline_dumper" + a.PatternDesc = "Most commits pushed near the deadline (>50% in final 20% of time)" + return + } + } + + // Check for daily builder: consistent activity + if a.ConsistencyScore >= 70 { + a.Pattern = "daily_builder" + a.PatternDesc = "Consistent daily activity throughout the period" + return + } + + if a.ConsistencyScore >= 40 { + a.Pattern = "moderate_builder" + a.PatternDesc = "Moderately consistent activity" + return + } + + // Check for burst pattern + if float64(a.MaxDailyCommits) > a.AveragePerDay*3 { + a.Pattern = "burst_coder" + a.PatternDesc = "Activity comes in bursts with quiet periods between" + return + } + + a.Pattern = "sporadic" + a.PatternDesc = "Irregular activity pattern" +} + +// calculateConsistencyScore calculates how consistent the activity is +// Returns 0-100, where 100 is perfectly consistent +func calculateConsistencyScore(activities []DailyActivity, totalDays int) float64 { + if totalDays == 0 || len(activities) == 0 { + return 0 + } + + // Calculate activity rate + activeDays := 0 + for _, a := range activities { + if a.Commits > 0 { + activeDays++ + } + } + + activityRate := float64(activeDays) / float64(totalDays) * 100 + + // Calculate variance in daily commits + var sum float64 + for _, a := range activities { + sum += float64(a.Commits) + } + mean := sum / float64(len(activities)) + + var variance float64 + for _, a := range activities { + diff := float64(a.Commits) - mean + variance += diff * diff + } + variance /= float64(len(activities)) + + // Lower variance = more consistent + // Normalize variance to a 0-100 scale (inverse) + varianceScore := 100 / (1 + variance/mean) + + // Combine activity rate and variance score + return (activityRate + varianceScore) / 2 +} + +// ContributorVolumePattern represents a contributor's work pattern +type ContributorVolumePattern struct { + Login string `json:"login"` + Pattern string `json:"pattern"` + TotalCommits int `json:"total_commits"` + DailyAverage float64 `json:"daily_average"` + PeakDay string `json:"peak_day"` + PeakDayCommits int `json:"peak_day_commits"` +} + +// AnalyzeContributorPatterns analyzes each contributor's work pattern +func AnalyzeContributorPatterns(contributorActivities map[string][]DailyActivity, hackathonStart, hackathonEnd time.Time) []ContributorVolumePattern { + var patterns []ContributorVolumePattern + + for login, activities := range contributorActivities { + analysis := AnalyzeVolume(activities, hackathonStart, hackathonEnd) + + peakDay := "" + peakCommits := 0 + for _, a := range activities { + if a.Commits > peakCommits { + peakCommits = a.Commits + peakDay = a.Date.Format("2006-01-02") + } + } + + patterns = append(patterns, ContributorVolumePattern{ + Login: login, + Pattern: analysis.Pattern, + TotalCommits: analysis.TotalCommits, + DailyAverage: analysis.AveragePerDay, + PeakDay: peakDay, + PeakDayCommits: peakCommits, + }) + } + + // Sort by total commits + sort.Slice(patterns, func(i, j int) bool { + return patterns[i].TotalCommits > patterns[j].TotalCommits + }) + + return patterns +} diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..33ae2c3 --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,95 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/harshpatel5940/gitvigil/internal/database" + "github.com/rs/zerolog" +) + +type Handler struct { + db *database.DB + logger zerolog.Logger +} + +func NewHandler(db *database.DB, logger zerolog.Logger) *Handler { + return &Handler{ + db: db, + logger: logger.With().Str("component", "api").Logger(), + } +} + +// Router returns a chi router with all API routes +func (h *Handler) Router() chi.Router { + r := chi.NewRouter() + + // Repositories + r.Get("/repositories", h.ListRepositories) + r.Get("/repositories/{id}", h.GetRepository) + + // Installations + r.Get("/installations", h.ListInstallations) + r.Get("/installations/{id}", h.GetInstallation) + r.Get("/installations/{id}/repositories", h.ListInstallationRepositories) + + // Stats + r.Get("/stats", h.GetStats) + + return r +} + +// JSON response helpers + +type ErrorResponse struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` +} + +func (h *Handler) respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + h.logger.Error().Err(err).Msg("failed to encode JSON response") + } +} + +func (h *Handler) respondError(w http.ResponseWriter, status int, message string) { + h.respondJSON(w, status, ErrorResponse{ + Error: http.StatusText(status), + Message: message, + }) +} + +// Pagination helpers + +type PaginationParams struct { + Page int + PerPage int + Offset int +} + +func (h *Handler) getPagination(r *http.Request) PaginationParams { + page := 1 + perPage := 20 + + if p := r.URL.Query().Get("page"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + page = v + } + } + + if pp := r.URL.Query().Get("per_page"); pp != "" { + if v, err := strconv.Atoi(pp); err == nil && v > 0 && v <= 100 { + perPage = v + } + } + + return PaginationParams{ + Page: page, + PerPage: perPage, + Offset: (page - 1) * perPage, + } +} diff --git a/internal/api/installations.go b/internal/api/installations.go new file mode 100644 index 0000000..6c51d83 --- /dev/null +++ b/internal/api/installations.go @@ -0,0 +1,135 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/harshpatel5940/gitvigil/internal/models" +) + +type InstallationResponse struct { + ID int64 `json:"id"` + InstallationID int64 `json:"installation_id"` + AccountLogin string `json:"account_login"` + AccountType string `json:"account_type"` + RepoCount int `json:"repo_count"` + AlertCount int `json:"alert_count"` + CommitCount int `json:"commit_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type InstallationsListResponse struct { + Installations []InstallationResponse `json:"installations"` + Total int `json:"total"` +} + +func installationToResponse(i *models.InstallationWithStats) InstallationResponse { + return InstallationResponse{ + ID: i.ID, + InstallationID: i.InstallationID, + AccountLogin: i.AccountLogin, + AccountType: i.AccountType, + RepoCount: i.RepoCount, + AlertCount: i.AlertCount, + CommitCount: i.CommitCount, + CreatedAt: i.CreatedAt, + UpdatedAt: i.UpdatedAt, + } +} + +func (h *Handler) ListInstallations(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + store := models.NewInstallationStore(h.db.Pool) + installations, err := store.List(ctx) + if err != nil { + h.logger.Error().Err(err).Msg("failed to list installations") + h.respondError(w, http.StatusInternalServerError, "failed to list installations") + return + } + + response := InstallationsListResponse{ + Installations: make([]InstallationResponse, 0, len(installations)), + Total: len(installations), + } + + for _, inst := range installations { + response.Installations = append(response.Installations, installationToResponse(inst)) + } + + h.respondJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetInstallation(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + h.respondError(w, http.StatusBadRequest, "invalid installation ID") + return + } + + store := models.NewInstallationStore(h.db.Pool) + installation, err := store.GetByID(ctx, id) + if err != nil { + h.logger.Error().Err(err).Int64("id", id).Msg("failed to get installation") + h.respondError(w, http.StatusNotFound, "installation not found") + return + } + + h.respondJSON(w, http.StatusOK, installationToResponse(installation)) +} + +func (h *Handler) ListInstallationRepositories(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + h.respondError(w, http.StatusBadRequest, "invalid installation ID") + return + } + + store := models.NewRepositoryStore(h.db.Pool) + repos, err := store.ListByInstallation(ctx, id) + if err != nil { + h.logger.Error().Err(err).Int64("installation_id", id).Msg("failed to list repositories") + h.respondError(w, http.StatusInternalServerError, "failed to list repositories") + return + } + + // Convert to response format (simple version without stats for this endpoint) + type SimpleRepo struct { + ID int64 `json:"id"` + GitHubID int64 `json:"github_id"` + FullName string `json:"full_name"` + HasLicense bool `json:"has_license"` + StreakStatus string `json:"streak_status"` + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` + } + + response := struct { + Repositories []SimpleRepo `json:"repositories"` + Total int `json:"total"` + }{ + Repositories: make([]SimpleRepo, 0, len(repos)), + Total: len(repos), + } + + for _, repo := range repos { + response.Repositories = append(response.Repositories, SimpleRepo{ + ID: repo.ID, + GitHubID: repo.GitHubID, + FullName: repo.FullName, + HasLicense: repo.HasLicense, + StreakStatus: repo.StreakStatus, + LastActivityAt: repo.LastActivityAt, + }) + } + + h.respondJSON(w, http.StatusOK, response) +} diff --git a/internal/api/repositories.go b/internal/api/repositories.go new file mode 100644 index 0000000..da66f99 --- /dev/null +++ b/internal/api/repositories.go @@ -0,0 +1,100 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/harshpatel5940/gitvigil/internal/models" +) + +type RepositoryResponse struct { + ID int64 `json:"id"` + GitHubID int64 `json:"github_id"` + InstallationID int64 `json:"installation_id"` + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + HasLicense bool `json:"has_license"` + LicenseSPDXID *string `json:"license_spdx_id,omitempty"` + StreakStatus string `json:"streak_status"` + LastActivityAt *time.Time `json:"last_activity_at,omitempty"` + AlertsCount int `json:"alerts_count"` + CommitsCount int `json:"commits_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepositoriesListResponse struct { + Repositories []RepositoryResponse `json:"repositories"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func repoToResponse(r *models.RepositoryWithStats) RepositoryResponse { + return RepositoryResponse{ + ID: r.ID, + GitHubID: r.GitHubID, + InstallationID: r.InstallationID, + Owner: r.Owner, + Name: r.Name, + FullName: r.FullName, + HasLicense: r.HasLicense, + LicenseSPDXID: r.LicenseSPDXID, + StreakStatus: r.StreakStatus, + LastActivityAt: r.LastActivityAt, + AlertsCount: r.AlertsCount, + CommitsCount: r.CommitsCount, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func (h *Handler) ListRepositories(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pagination := h.getPagination(r) + + store := models.NewRepositoryStore(h.db.Pool) + repos, total, err := store.ListAll(ctx, pagination.PerPage, pagination.Offset) + if err != nil { + h.logger.Error().Err(err).Msg("failed to list repositories") + h.respondError(w, http.StatusInternalServerError, "failed to list repositories") + return + } + + response := RepositoriesListResponse{ + Repositories: make([]RepositoryResponse, 0, len(repos)), + Total: total, + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + for _, repo := range repos { + response.Repositories = append(response.Repositories, repoToResponse(repo)) + } + + h.respondJSON(w, http.StatusOK, response) +} + +func (h *Handler) GetRepository(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + h.respondError(w, http.StatusBadRequest, "invalid repository ID") + return + } + + store := models.NewRepositoryStore(h.db.Pool) + repo, err := store.GetByID(ctx, id) + if err != nil { + h.logger.Error().Err(err).Int64("id", id).Msg("failed to get repository") + h.respondError(w, http.StatusNotFound, "repository not found") + return + } + + h.respondJSON(w, http.StatusOK, repoToResponse(repo)) +} diff --git a/internal/api/stats.go b/internal/api/stats.go new file mode 100644 index 0000000..b12b653 --- /dev/null +++ b/internal/api/stats.go @@ -0,0 +1,69 @@ +package api + +import ( + "net/http" + "time" +) + +type StatsResponse struct { + Installations int `json:"installations"` + Repositories int `json:"repositories"` + TotalCommits int `json:"total_commits"` + TotalAlerts int `json:"total_alerts"` + ActiveRepos int `json:"active_repos"` + AtRiskRepos int `json:"at_risk_repos"` + BackdateAlerts int `json:"backdate_alerts"` + ForcePushAlerts int `json:"force_push_alerts"` + AlertsBySeverity map[string]int `json:"alerts_by_severity"` + GeneratedAt time.Time `json:"generated_at"` +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats := StatsResponse{ + AlertsBySeverity: make(map[string]int), + GeneratedAt: time.Now(), + } + + // Get counts + queries := []struct { + query string + target *int + }{ + {"SELECT COUNT(*) FROM installations", &stats.Installations}, + {"SELECT COUNT(*) FROM repositories", &stats.Repositories}, + {"SELECT COUNT(*) FROM commits", &stats.TotalCommits}, + {"SELECT COUNT(*) FROM alerts", &stats.TotalAlerts}, + {"SELECT COUNT(*) FROM repositories WHERE streak_status = 'active'", &stats.ActiveRepos}, + {"SELECT COUNT(*) FROM repositories WHERE streak_status = 'at_risk'", &stats.AtRiskRepos}, + {"SELECT COUNT(*) FROM alerts WHERE alert_type LIKE 'backdate%'", &stats.BackdateAlerts}, + {"SELECT COUNT(*) FROM alerts WHERE alert_type = 'force_push'", &stats.ForcePushAlerts}, + } + + for _, q := range queries { + if err := h.db.Pool.QueryRow(ctx, q.query).Scan(q.target); err != nil { + h.logger.Error().Err(err).Str("query", q.query).Msg("failed to get stat") + // Continue with zero value + } + } + + // Get alerts by severity + rows, err := h.db.Pool.Query(ctx, ` + SELECT severity, COUNT(*) as count + FROM alerts + GROUP BY severity + `) + if err == nil { + defer rows.Close() + for rows.Next() { + var severity string + var count int + if err := rows.Scan(&severity, &count); err == nil { + stats.AlertsBySeverity[severity] = count + } + } + } + + h.respondJSON(w, http.StatusOK, stats) +} diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..7f44620 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,150 @@ +package auth + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/harshpatel5940/gitvigil/internal/config" + "github.com/rs/zerolog" +) + +type Handler struct { + cfg *config.Config + logger zerolog.Logger +} + +func NewHandler(cfg *config.Config, logger zerolog.Logger) *Handler { + return &Handler{ + cfg: cfg, + logger: logger.With().Str("component", "auth").Logger(), + } +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` +} + +type UserInfo struct { + Login string `json:"login"` + ID int64 `json:"id"` + AvatarURL string `json:"avatar_url"` + Name string `json:"name"` + Email string `json:"email"` +} + +type CallbackResponse struct { + Success bool `json:"success"` + User *UserInfo `json:"user,omitempty"` + Error string `json:"error,omitempty"` +} + +func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + if code == "" { + h.respondError(w, "missing code parameter", http.StatusBadRequest) + return + } + + // Exchange code for access token + token, err := h.exchangeCodeForToken(code) + if err != nil { + h.logger.Error().Err(err).Msg("failed to exchange code for token") + h.respondError(w, "failed to authenticate", http.StatusInternalServerError) + return + } + + // Get user info + user, err := h.getUserInfo(token.AccessToken) + if err != nil { + h.logger.Error().Err(err).Msg("failed to get user info") + h.respondError(w, "failed to get user info", http.StatusInternalServerError) + return + } + + h.logger.Info(). + Str("login", user.Login). + Int64("id", user.ID). + Msg("user authenticated") + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CallbackResponse{ + Success: true, + User: user, + }) +} + +func (h *Handler) exchangeCodeForToken(code string) (*TokenResponse, error) { + data := url.Values{} + data.Set("client_id", h.cfg.ClientID) + data.Set("client_secret", h.cfg.ClientSecret) + data.Set("code", code) + + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("token exchange failed: %s", string(body)) + } + + var token TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, err + } + + return &token, nil +} + +func (h *Handler) getUserInfo(accessToken string) (*UserInfo, error) { + req, err := http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("user info request failed: %s", string(body)) + } + + var user UserInfo + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, err + } + + return &user, nil +} + +func (h *Handler) respondError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(CallbackResponse{ + Success: false, + Error: message, + }) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9bcca54 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "os" + "strconv" + + "github.com/joho/godotenv" +) + +type Config struct { + // Server + Port string + BaseURL string + + // GitHub App + AppID int64 + ClientID string + ClientSecret string + WebhookSecret string + PrivateKeyPath string + PrivateKey []byte + + // Database + DatabaseURL string + + // Detection thresholds + BackdateSuspiciousHours int + BackdateCriticalHours int + StreakInactivityHours int +} + +func Load() (*Config, error) { + // Load .env file (ignore error if file doesn't exist) + _ = godotenv.Load() + + cfg := &Config{ + Port: getEnv("PORT", "8080"), + BaseURL: getEnv("BASE_URL", "http://localhost:8080"), + ClientID: os.Getenv("GITHUB_APP_CLIENT_ID"), + ClientSecret: os.Getenv("GITHUB_APP_CLIENT_SECRET"), + WebhookSecret: getEnv("GITHUB_WEBHOOK_SECRET", ""), + PrivateKeyPath: getEnv("GITHUB_PRIVATE_KEY_PATH", ""), + DatabaseURL: getEnv("DATABASE_URL", ""), + BackdateSuspiciousHours: getEnvInt("BACKDATE_SUSPICIOUS_HOURS", 24), + BackdateCriticalHours: getEnvInt("BACKDATE_CRITICAL_HOURS", 72), + StreakInactivityHours: getEnvInt("STREAK_INACTIVITY_HOURS", 72), + } + + // Parse App ID + appIDStr := os.Getenv("GITHUB_APP_ID") + if appIDStr == "" { + return nil, fmt.Errorf("GITHUB_APP_ID is required") + } + appID, err := strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid GITHUB_APP_ID: %w", err) + } + cfg.AppID = appID + + // Load private key if path is specified + if cfg.PrivateKeyPath != "" { + key, err := os.ReadFile(cfg.PrivateKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read private key: %w", err) + } + cfg.PrivateKey = key + } + + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if i, err := strconv.Atoi(value); err == nil { + return i + } + } + return defaultValue +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..4ef7bb5 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,46 @@ +package database + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(ctx context.Context, databaseURL string) (*DB, error) { + if databaseURL == "" { + return nil, fmt.Errorf("database URL is required") + } + + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse database URL: %w", err) + } + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &DB{Pool: pool}, nil +} + +func (db *DB) Close() { + if db.Pool != nil { + db.Pool.Close() + } +} + +func (db *DB) Health(ctx context.Context) error { + return db.Pool.Ping(ctx) +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go new file mode 100644 index 0000000..8ad788e --- /dev/null +++ b/internal/database/migrate.go @@ -0,0 +1,32 @@ +package database + +import ( + "embed" + "fmt" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +func RunMigrations(databaseURL string) error { + source, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("failed to create migration source: %w", err) + } + + m, err := migrate.NewWithSourceInstance("iofs", source, databaseURL) + if err != nil { + return fmt.Errorf("failed to create migrate instance: %w", err) + } + defer m.Close() + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("failed to run migrations: %w", err) + } + + return nil +} diff --git a/internal/database/migrations/000001_init_schema.down.sql b/internal/database/migrations/000001_init_schema.down.sql new file mode 100644 index 0000000..2e24887 --- /dev/null +++ b/internal/database/migrations/000001_init_schema.down.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_contributors_repo; +DROP INDEX IF EXISTS idx_repositories_streak; +DROP INDEX IF EXISTS idx_repositories_installation; +DROP TABLE IF EXISTS contributors; +DROP TABLE IF EXISTS repositories; +DROP TABLE IF EXISTS installations; diff --git a/internal/database/migrations/000001_init_schema.up.sql b/internal/database/migrations/000001_init_schema.up.sql new file mode 100644 index 0000000..ca3a89f --- /dev/null +++ b/internal/database/migrations/000001_init_schema.up.sql @@ -0,0 +1,50 @@ +-- Installations table: Track GitHub App installations +CREATE TABLE installations ( + id BIGSERIAL PRIMARY KEY, + installation_id BIGINT UNIQUE NOT NULL, + account_login VARCHAR(255) NOT NULL, + account_type VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Repositories table: Monitored repositories +CREATE TABLE repositories ( + id BIGSERIAL PRIMARY KEY, + github_id BIGINT UNIQUE NOT NULL, + installation_id BIGINT REFERENCES installations(installation_id), + owner VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + full_name VARCHAR(511) NOT NULL, + default_branch VARCHAR(255) DEFAULT 'main', + has_license BOOLEAN DEFAULT FALSE, + license_spdx_id VARCHAR(50), + last_push_at TIMESTAMPTZ, + last_activity_at TIMESTAMPTZ, + streak_status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(owner, name) +); + +CREATE INDEX idx_repositories_installation ON repositories(installation_id); +CREATE INDEX idx_repositories_streak ON repositories(streak_status); + +-- Contributors table: Track team members +CREATE TABLE contributors ( + id BIGSERIAL PRIMARY KEY, + repository_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, + github_login VARCHAR(255), + email VARCHAR(255), + name VARCHAR(255), + total_commits INT DEFAULT 0, + total_additions BIGINT DEFAULT 0, + total_deletions BIGINT DEFAULT 0, + first_commit_at TIMESTAMPTZ, + last_commit_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(repository_id, email) +); + +CREATE INDEX idx_contributors_repo ON contributors(repository_id); diff --git a/internal/database/migrations/000002_add_commits.down.sql b/internal/database/migrations/000002_add_commits.down.sql new file mode 100644 index 0000000..f9ebbf1 --- /dev/null +++ b/internal/database/migrations/000002_add_commits.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_alerts_unacked; +DROP INDEX IF EXISTS idx_alerts_severity; +DROP INDEX IF EXISTS idx_alerts_type; +DROP INDEX IF EXISTS idx_alerts_repo; +DROP INDEX IF EXISTS idx_push_events_forced; +DROP INDEX IF EXISTS idx_push_events_repo; +DROP INDEX IF EXISTS idx_commits_backdated; +DROP INDEX IF EXISTS idx_commits_pushed_at; +DROP INDEX IF EXISTS idx_commits_repo; +DROP TABLE IF EXISTS alerts; +DROP TABLE IF EXISTS push_events; +DROP TABLE IF EXISTS commits; diff --git a/internal/database/migrations/000002_add_commits.up.sql b/internal/database/migrations/000002_add_commits.up.sql new file mode 100644 index 0000000..3d7d5eb --- /dev/null +++ b/internal/database/migrations/000002_add_commits.up.sql @@ -0,0 +1,63 @@ +-- Commits table: Store commit metadata for analysis +CREATE TABLE commits ( + id BIGSERIAL PRIMARY KEY, + repository_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, + sha VARCHAR(40) UNIQUE NOT NULL, + message TEXT, + author_email VARCHAR(255), + author_name VARCHAR(255), + author_date TIMESTAMPTZ NOT NULL, + committer_date TIMESTAMPTZ NOT NULL, + pushed_at TIMESTAMPTZ NOT NULL, + additions INT DEFAULT 0, + deletions INT DEFAULT 0, + is_conventional BOOLEAN DEFAULT FALSE, + conventional_type VARCHAR(50), + conventional_scope VARCHAR(100), + is_backdated BOOLEAN DEFAULT FALSE, + backdate_hours INT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_commits_repo ON commits(repository_id); +CREATE INDEX idx_commits_pushed_at ON commits(pushed_at); +CREATE INDEX idx_commits_backdated ON commits(is_backdated) WHERE is_backdated = TRUE; + +-- Push events table: Track all push events +CREATE TABLE push_events ( + id BIGSERIAL PRIMARY KEY, + repository_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, + push_id BIGINT, + ref VARCHAR(255), + before_sha VARCHAR(40), + after_sha VARCHAR(40), + forced BOOLEAN DEFAULT FALSE, + pusher_login VARCHAR(255), + commit_count INT, + distinct_count INT, + received_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_push_events_repo ON push_events(repository_id); +CREATE INDEX idx_push_events_forced ON push_events(forced) WHERE forced = TRUE; + +-- Alerts table: Detection alerts and flags +CREATE TABLE alerts ( + id BIGSERIAL PRIMARY KEY, + repository_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, + commit_sha VARCHAR(40), + push_event_id BIGINT REFERENCES push_events(id), + alert_type VARCHAR(50) NOT NULL, + severity VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + metadata JSONB, + acknowledged BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_alerts_repo ON alerts(repository_id); +CREATE INDEX idx_alerts_type ON alerts(alert_type); +CREATE INDEX idx_alerts_severity ON alerts(severity); +CREATE INDEX idx_alerts_unacked ON alerts(acknowledged) WHERE acknowledged = FALSE; diff --git a/internal/database/migrations/000003_add_daily_stats.down.sql b/internal/database/migrations/000003_add_daily_stats.down.sql new file mode 100644 index 0000000..aa7effa --- /dev/null +++ b/internal/database/migrations/000003_add_daily_stats.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_daily_stats_contributor; +DROP INDEX IF EXISTS idx_daily_stats_repo_date; +DROP TABLE IF EXISTS daily_stats; diff --git a/internal/database/migrations/000003_add_daily_stats.up.sql b/internal/database/migrations/000003_add_daily_stats.up.sql new file mode 100644 index 0000000..f82a705 --- /dev/null +++ b/internal/database/migrations/000003_add_daily_stats.up.sql @@ -0,0 +1,15 @@ +-- Daily contribution stats for volume analysis +CREATE TABLE daily_stats ( + id BIGSERIAL PRIMARY KEY, + repository_id BIGINT REFERENCES repositories(id) ON DELETE CASCADE, + contributor_id BIGINT REFERENCES contributors(id) ON DELETE CASCADE, + stat_date DATE NOT NULL, + commit_count INT DEFAULT 0, + additions INT DEFAULT 0, + deletions INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(repository_id, contributor_id, stat_date) +); + +CREATE INDEX idx_daily_stats_repo_date ON daily_stats(repository_id, stat_date); +CREATE INDEX idx_daily_stats_contributor ON daily_stats(contributor_id); diff --git a/internal/detection/detector.go b/internal/detection/detector.go new file mode 100644 index 0000000..9fc1407 --- /dev/null +++ b/internal/detection/detector.go @@ -0,0 +1,144 @@ +package detection + +import ( + "context" + "time" + + "github.com/harshpatel5940/gitvigil/internal/config" + "github.com/harshpatel5940/gitvigil/internal/database" + ghclient "github.com/harshpatel5940/gitvigil/internal/github" + "github.com/harshpatel5940/gitvigil/internal/models" + "github.com/rs/zerolog" +) + +type Detector struct { + cfg *config.Config + db *database.DB + gh *ghclient.AppClient + logger zerolog.Logger +} + +func NewDetector(cfg *config.Config, db *database.DB, gh *ghclient.AppClient, logger zerolog.Logger) *Detector { + return &Detector{ + cfg: cfg, + db: db, + gh: gh, + logger: logger.With().Str("component", "detector").Logger(), + } +} + +type BackdateResult struct { + CommitSHA string + AuthorDate time.Time + PushedAt time.Time + DifferenceHours int + IsSuspicious bool + IsCritical bool +} + +func (d *Detector) AnalyzeBackdate(authorDate, pushedAt time.Time) *BackdateResult { + diffHours := int(pushedAt.Sub(authorDate).Hours()) + + return &BackdateResult{ + AuthorDate: authorDate, + PushedAt: pushedAt, + DifferenceHours: diffHours, + IsSuspicious: diffHours > d.cfg.BackdateSuspiciousHours, + IsCritical: diffHours > d.cfg.BackdateCriticalHours, + } +} + +func (d *Detector) CheckLicense(ctx context.Context, installationID int64, owner, repo string) (bool, string, error) { + client, err := d.gh.GetInstallationClient(installationID) + if err != nil { + return false, "", err + } + + license, _, err := client.Repositories.License(ctx, owner, repo) + if err != nil { + // 404 means no license + return false, "", nil + } + + if license.License != nil { + return true, license.License.GetSPDXID(), nil + } + + return false, "", nil +} + +func (d *Detector) CheckStreaks(ctx context.Context) error { + repoStore := models.NewRepositoryStore(d.db.Pool) + alertStore := models.NewAlertStore(d.db.Pool) + + repos, err := repoStore.ListAtRisk(ctx, d.cfg.StreakInactivityHours) + if err != nil { + return err + } + + for _, repo := range repos { + // Update streak status + if err := repoStore.UpdateStreakStatus(ctx, repo.ID, "at_risk"); err != nil { + d.logger.Error().Err(err).Int64("repo_id", repo.ID).Msg("failed to update streak status") + continue + } + + // Create alert + alert := &models.Alert{ + RepositoryID: repo.ID, + AlertType: models.AlertStreakAtRisk, + Severity: models.SeverityWarning, + Title: "Activity streak at risk", + Description: "Repository has been inactive for more than 72 hours", + Metadata: map[string]interface{}{ + "last_activity_at": repo.LastActivityAt, + "inactivity_hours": d.cfg.StreakInactivityHours, + }, + } + + if err := alertStore.Create(ctx, alert); err != nil { + d.logger.Error().Err(err).Int64("repo_id", repo.ID).Msg("failed to create streak alert") + } + + d.logger.Info(). + Str("repo", repo.FullName). + Time("last_activity", *repo.LastActivityAt). + Msg("repository marked as at risk") + } + + return nil +} + +func (d *Detector) ValidateLicenseForRepo(ctx context.Context, repoID, installationID int64, owner, name string) error { + hasLicense, spdxID, err := d.CheckLicense(ctx, installationID, owner, name) + if err != nil { + return err + } + + repoStore := models.NewRepositoryStore(d.db.Pool) + var spdxPtr *string + if spdxID != "" { + spdxPtr = &spdxID + } + + if err := repoStore.UpdateLicense(ctx, repoID, hasLicense, spdxPtr); err != nil { + return err + } + + // Create alert if no license + if !hasLicense { + alertStore := models.NewAlertStore(d.db.Pool) + alert := &models.Alert{ + RepositoryID: repoID, + AlertType: models.AlertNoLicense, + Severity: models.SeverityInfo, + Title: "No license file found", + Description: "Repository does not have a LICENSE file", + } + if err := alertStore.Create(ctx, alert); err != nil { + d.logger.Error().Err(err).Int64("repo_id", repoID).Msg("failed to create license alert") + } + } + + return nil +} diff --git a/internal/github/app.go b/internal/github/app.go new file mode 100644 index 0000000..aa384e1 --- /dev/null +++ b/internal/github/app.go @@ -0,0 +1,53 @@ +package github + +import ( + "fmt" + "net/http" + + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/google/go-github/v68/github" +) + +type AppClient struct { + appID int64 + privateKey []byte + transport *ghinstallation.AppsTransport + client *github.Client +} + +func NewAppClient(appID int64, privateKey []byte) (*AppClient, error) { + if len(privateKey) == 0 { + return nil, fmt.Errorf("private key is required") + } + + transport, err := ghinstallation.NewAppsTransport(http.DefaultTransport, appID, privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create apps transport: %w", err) + } + + client := github.NewClient(&http.Client{Transport: transport}) + + return &AppClient{ + appID: appID, + privateKey: privateKey, + transport: transport, + client: client, + }, nil +} + +func (a *AppClient) GetInstallationClient(installationID int64) (*github.Client, error) { + transport, err := ghinstallation.New(http.DefaultTransport, a.appID, installationID, a.privateKey) + if err != nil { + return nil, fmt.Errorf("failed to create installation transport: %w", err) + } + + return github.NewClient(&http.Client{Transport: transport}), nil +} + +func (a *AppClient) AppClient() *github.Client { + return a.client +} + +func (a *AppClient) AppID() int64 { + return a.appID +} diff --git a/internal/models/alert.go b/internal/models/alert.go new file mode 100644 index 0000000..83b027a --- /dev/null +++ b/internal/models/alert.go @@ -0,0 +1,121 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type AlertType string + +const ( + AlertBackdateSuspicious AlertType = "backdate_suspicious" + AlertBackdateCritical AlertType = "backdate_critical" + AlertForcePush AlertType = "force_push" + AlertNoLicense AlertType = "no_license" + AlertStreakAtRisk AlertType = "streak_at_risk" + AlertNonConventional AlertType = "non_conventional_commit" +) + +type Severity string + +const ( + SeverityInfo Severity = "info" + SeverityWarning Severity = "warning" + SeverityCritical Severity = "critical" +) + +type Alert struct { + ID int64 + RepositoryID int64 + CommitSHA *string + PushEventID *int64 + AlertType AlertType + Severity Severity + Title string + Description string + Metadata map[string]interface{} + Acknowledged bool + CreatedAt time.Time +} + +type AlertStore struct { + pool *pgxpool.Pool +} + +func NewAlertStore(pool *pgxpool.Pool) *AlertStore { + return &AlertStore{pool: pool} +} + +func (s *AlertStore) Create(ctx context.Context, alert *Alert) error { + return s.pool.QueryRow(ctx, ` + INSERT INTO alerts (repository_id, commit_sha, push_event_id, alert_type, severity, title, description, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at + `, alert.RepositoryID, alert.CommitSHA, alert.PushEventID, alert.AlertType, + alert.Severity, alert.Title, alert.Description, alert.Metadata, + ).Scan(&alert.ID, &alert.CreatedAt) +} + +func (s *AlertStore) ListByRepository(ctx context.Context, repoID int64) ([]*Alert, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, repository_id, commit_sha, push_event_id, alert_type, severity, + title, description, metadata, acknowledged, created_at + FROM alerts WHERE repository_id = $1 + ORDER BY created_at DESC + `, repoID) + if err != nil { + return nil, err + } + defer rows.Close() + + var alerts []*Alert + for rows.Next() { + var a Alert + err := rows.Scan( + &a.ID, &a.RepositoryID, &a.CommitSHA, &a.PushEventID, &a.AlertType, + &a.Severity, &a.Title, &a.Description, &a.Metadata, &a.Acknowledged, &a.CreatedAt, + ) + if err != nil { + return nil, err + } + alerts = append(alerts, &a) + } + return alerts, nil +} + +func (s *AlertStore) CountByRepository(ctx context.Context, repoID int64) (map[AlertType]int, map[Severity]int, error) { + typeCounts := make(map[AlertType]int) + severityCounts := make(map[Severity]int) + + rows, err := s.pool.Query(ctx, ` + SELECT alert_type, severity, COUNT(*) as count + FROM alerts WHERE repository_id = $1 + GROUP BY alert_type, severity + `, repoID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + for rows.Next() { + var alertType AlertType + var severity Severity + var count int + if err := rows.Scan(&alertType, &severity, &count); err != nil { + return nil, nil, err + } + typeCounts[alertType] += count + severityCounts[severity] += count + } + + return typeCounts, severityCounts, nil +} + +func (s *AlertStore) Acknowledge(ctx context.Context, id int64) error { + _, err := s.pool.Exec(ctx, ` + UPDATE alerts SET acknowledged = TRUE WHERE id = $1 + `, id) + return err +} diff --git a/internal/models/commit.go b/internal/models/commit.go new file mode 100644 index 0000000..7b35c99 --- /dev/null +++ b/internal/models/commit.go @@ -0,0 +1,108 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Commit struct { + ID int64 + RepositoryID int64 + SHA string + Message string + AuthorEmail string + AuthorName string + AuthorDate time.Time + CommitterDate time.Time + PushedAt time.Time + Additions int + Deletions int + IsConventional bool + ConventionalType *string + ConventionalScope *string + IsBackdated bool + BackdateHours *int + CreatedAt time.Time +} + +type CommitStore struct { + pool *pgxpool.Pool +} + +func NewCommitStore(pool *pgxpool.Pool) *CommitStore { + return &CommitStore{pool: pool} +} + +func (s *CommitStore) ListByRepository(ctx context.Context, repoID int64, limit int) ([]*Commit, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, repository_id, sha, message, author_email, author_name, + author_date, committer_date, pushed_at, additions, deletions, + is_conventional, conventional_type, conventional_scope, + is_backdated, backdate_hours, created_at + FROM commits WHERE repository_id = $1 + ORDER BY pushed_at DESC + LIMIT $2 + `, repoID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var commits []*Commit + for rows.Next() { + var c Commit + err := rows.Scan( + &c.ID, &c.RepositoryID, &c.SHA, &c.Message, &c.AuthorEmail, &c.AuthorName, + &c.AuthorDate, &c.CommitterDate, &c.PushedAt, &c.Additions, &c.Deletions, + &c.IsConventional, &c.ConventionalType, &c.ConventionalScope, + &c.IsBackdated, &c.BackdateHours, &c.CreatedAt, + ) + if err != nil { + return nil, err + } + commits = append(commits, &c) + } + return commits, nil +} + +func (s *CommitStore) GetStats(ctx context.Context, repoID int64) (*CommitStats, error) { + var stats CommitStats + + err := s.pool.QueryRow(ctx, ` + SELECT + COUNT(*) as total_commits, + COUNT(*) FILTER (WHERE is_backdated) as backdated_count, + COUNT(*) FILTER (WHERE is_conventional) as conventional_count, + SUM(additions) as total_additions, + SUM(deletions) as total_deletions + FROM commits WHERE repository_id = $1 + `, repoID).Scan( + &stats.TotalCommits, &stats.BackdatedCount, &stats.ConventionalCount, + &stats.TotalAdditions, &stats.TotalDeletions, + ) + if err != nil { + return nil, err + } + + return &stats, nil +} + +type CommitStats struct { + TotalCommits int + BackdatedCount int + ConventionalCount int + TotalAdditions int64 + TotalDeletions int64 +} + +func (s *CommitStore) CountBackdated(ctx context.Context, repoID int64) (suspicious, critical int, err error) { + err = s.pool.QueryRow(ctx, ` + SELECT + COUNT(*) FILTER (WHERE is_backdated AND backdate_hours <= 72) as suspicious, + COUNT(*) FILTER (WHERE backdate_hours > 72) as critical + FROM commits WHERE repository_id = $1 AND is_backdated = TRUE + `, repoID).Scan(&suspicious, &critical) + return +} diff --git a/internal/models/contributor.go b/internal/models/contributor.go new file mode 100644 index 0000000..dc1576f --- /dev/null +++ b/internal/models/contributor.go @@ -0,0 +1,142 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Contributor struct { + ID int64 + RepositoryID int64 + GitHubLogin *string + Email string + Name *string + TotalCommits int + TotalAdditions int64 + TotalDeletions int64 + FirstCommitAt *time.Time + LastCommitAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type ContributorStore struct { + pool *pgxpool.Pool +} + +func NewContributorStore(pool *pgxpool.Pool) *ContributorStore { + return &ContributorStore{pool: pool} +} + +func (s *ContributorStore) ListByRepository(ctx context.Context, repoID int64) ([]*Contributor, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, repository_id, github_login, email, name, total_commits, + total_additions, total_deletions, first_commit_at, last_commit_at, + created_at, updated_at + FROM contributors WHERE repository_id = $1 + ORDER BY total_commits DESC + `, repoID) + if err != nil { + return nil, err + } + defer rows.Close() + + var contributors []*Contributor + for rows.Next() { + var c Contributor + err := rows.Scan( + &c.ID, &c.RepositoryID, &c.GitHubLogin, &c.Email, &c.Name, + &c.TotalCommits, &c.TotalAdditions, &c.TotalDeletions, + &c.FirstCommitAt, &c.LastCommitAt, &c.CreatedAt, &c.UpdatedAt, + ) + if err != nil { + return nil, err + } + contributors = append(contributors, &c) + } + return contributors, nil +} + +func (s *ContributorStore) GetStats(ctx context.Context, repoID int64) (*ContributorStats, error) { + var stats ContributorStats + + err := s.pool.QueryRow(ctx, ` + SELECT + COUNT(*) as total_contributors, + SUM(total_commits) as total_commits, + SUM(total_additions) as total_additions, + SUM(total_deletions) as total_deletions + FROM contributors WHERE repository_id = $1 + `, repoID).Scan(&stats.TotalContributors, &stats.TotalCommits, &stats.TotalAdditions, &stats.TotalDeletions) + if err != nil { + return nil, err + } + + return &stats, nil +} + +type ContributorStats struct { + TotalContributors int + TotalCommits int + TotalAdditions int64 + TotalDeletions int64 +} + +type DailyStat struct { + ID int64 + RepositoryID int64 + ContributorID int64 + StatDate time.Time + CommitCount int + Additions int + Deletions int + CreatedAt time.Time +} + +type DailyStatsStore struct { + pool *pgxpool.Pool +} + +func NewDailyStatsStore(pool *pgxpool.Pool) *DailyStatsStore { + return &DailyStatsStore{pool: pool} +} + +func (s *DailyStatsStore) GetByRepository(ctx context.Context, repoID int64) ([]*DailyStat, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, repository_id, contributor_id, stat_date, commit_count, additions, deletions, created_at + FROM daily_stats WHERE repository_id = $1 + ORDER BY stat_date DESC + `, repoID) + if err != nil { + return nil, err + } + defer rows.Close() + + var stats []*DailyStat + for rows.Next() { + var d DailyStat + err := rows.Scan( + &d.ID, &d.RepositoryID, &d.ContributorID, &d.StatDate, + &d.CommitCount, &d.Additions, &d.Deletions, &d.CreatedAt, + ) + if err != nil { + return nil, err + } + stats = append(stats, &d) + } + return stats, nil +} + +func (s *DailyStatsStore) Upsert(ctx context.Context, stat *DailyStat) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO daily_stats (repository_id, contributor_id, stat_date, commit_count, additions, deletions) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (repository_id, contributor_id, stat_date) DO UPDATE SET + commit_count = daily_stats.commit_count + $4, + additions = daily_stats.additions + $5, + deletions = daily_stats.deletions + $6 + `, stat.RepositoryID, stat.ContributorID, stat.StatDate, stat.CommitCount, stat.Additions, stat.Deletions) + return err +} diff --git a/internal/models/installation.go b/internal/models/installation.go new file mode 100644 index 0000000..d6a7282 --- /dev/null +++ b/internal/models/installation.go @@ -0,0 +1,118 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Installation struct { + ID int64 + InstallationID int64 + AccountLogin string + AccountType string + CreatedAt time.Time + UpdatedAt time.Time +} + +type InstallationWithStats struct { + Installation + RepoCount int + AlertCount int + CommitCount int +} + +type InstallationStore struct { + pool *pgxpool.Pool +} + +func NewInstallationStore(pool *pgxpool.Pool) *InstallationStore { + return &InstallationStore{pool: pool} +} + +func (s *InstallationStore) List(ctx context.Context) ([]*InstallationWithStats, error) { + rows, err := s.pool.Query(ctx, ` + SELECT + i.id, i.installation_id, i.account_login, i.account_type, i.created_at, i.updated_at, + COALESCE(r.repo_count, 0) as repo_count, + COALESCE(a.alert_count, 0) as alert_count, + COALESCE(c.commit_count, 0) as commit_count + FROM installations i + LEFT JOIN ( + SELECT installation_id, COUNT(*) as repo_count + FROM repositories + GROUP BY installation_id + ) r ON r.installation_id = i.installation_id + LEFT JOIN ( + SELECT r.installation_id, COUNT(*) as alert_count + FROM alerts al + JOIN repositories r ON r.id = al.repository_id + GROUP BY r.installation_id + ) a ON a.installation_id = i.installation_id + LEFT JOIN ( + SELECT r.installation_id, COUNT(*) as commit_count + FROM commits c + JOIN repositories r ON r.id = c.repository_id + GROUP BY r.installation_id + ) c ON c.installation_id = i.installation_id + ORDER BY i.account_login + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var installations []*InstallationWithStats + for rows.Next() { + var i InstallationWithStats + err := rows.Scan( + &i.ID, &i.InstallationID, &i.AccountLogin, &i.AccountType, + &i.CreatedAt, &i.UpdatedAt, + &i.RepoCount, &i.AlertCount, &i.CommitCount, + ) + if err != nil { + return nil, err + } + installations = append(installations, &i) + } + return installations, nil +} + +func (s *InstallationStore) GetByID(ctx context.Context, installationID int64) (*InstallationWithStats, error) { + var i InstallationWithStats + err := s.pool.QueryRow(ctx, ` + SELECT + i.id, i.installation_id, i.account_login, i.account_type, i.created_at, i.updated_at, + COALESCE(r.repo_count, 0) as repo_count, + COALESCE(a.alert_count, 0) as alert_count, + COALESCE(c.commit_count, 0) as commit_count + FROM installations i + LEFT JOIN ( + SELECT installation_id, COUNT(*) as repo_count + FROM repositories + GROUP BY installation_id + ) r ON r.installation_id = i.installation_id + LEFT JOIN ( + SELECT r.installation_id, COUNT(*) as alert_count + FROM alerts al + JOIN repositories r ON r.id = al.repository_id + GROUP BY r.installation_id + ) a ON a.installation_id = i.installation_id + LEFT JOIN ( + SELECT r.installation_id, COUNT(*) as commit_count + FROM commits c + JOIN repositories r ON r.id = c.repository_id + GROUP BY r.installation_id + ) c ON c.installation_id = i.installation_id + WHERE i.installation_id = $1 + `, installationID).Scan( + &i.ID, &i.InstallationID, &i.AccountLogin, &i.AccountType, + &i.CreatedAt, &i.UpdatedAt, + &i.RepoCount, &i.AlertCount, &i.CommitCount, + ) + if err != nil { + return nil, err + } + return &i, nil +} diff --git a/internal/models/repository.go b/internal/models/repository.go new file mode 100644 index 0000000..47c022b --- /dev/null +++ b/internal/models/repository.go @@ -0,0 +1,235 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Repository struct { + ID int64 + GitHubID int64 + InstallationID int64 + Owner string + Name string + FullName string + DefaultBranch string + HasLicense bool + LicenseSPDXID *string + LastPushAt *time.Time + LastActivityAt *time.Time + StreakStatus string + CreatedAt time.Time + UpdatedAt time.Time +} + +type RepositoryWithStats struct { + Repository + AlertsCount int + CommitsCount int +} + +type RepositoryStore struct { + pool *pgxpool.Pool +} + +func NewRepositoryStore(pool *pgxpool.Pool) *RepositoryStore { + return &RepositoryStore{pool: pool} +} + +func (s *RepositoryStore) GetByGitHubID(ctx context.Context, githubID int64) (*Repository, error) { + var r Repository + err := s.pool.QueryRow(ctx, ` + SELECT id, github_id, installation_id, owner, name, full_name, default_branch, + has_license, license_spdx_id, last_push_at, last_activity_at, streak_status, + created_at, updated_at + FROM repositories WHERE github_id = $1 + `, githubID).Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +func (s *RepositoryStore) GetByFullName(ctx context.Context, owner, name string) (*Repository, error) { + var r Repository + err := s.pool.QueryRow(ctx, ` + SELECT id, github_id, installation_id, owner, name, full_name, default_branch, + has_license, license_spdx_id, last_push_at, last_activity_at, streak_status, + created_at, updated_at + FROM repositories WHERE owner = $1 AND name = $2 + `, owner, name).Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +func (s *RepositoryStore) UpdateLicense(ctx context.Context, id int64, hasLicense bool, spdxID *string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE repositories SET has_license = $2, license_spdx_id = $3, updated_at = NOW() + WHERE id = $1 + `, id, hasLicense, spdxID) + return err +} + +func (s *RepositoryStore) UpdateStreakStatus(ctx context.Context, id int64, status string) error { + _, err := s.pool.Exec(ctx, ` + UPDATE repositories SET streak_status = $2, updated_at = NOW() + WHERE id = $1 + `, id, status) + return err +} + +func (s *RepositoryStore) ListByInstallation(ctx context.Context, installationID int64) ([]*Repository, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, github_id, installation_id, owner, name, full_name, default_branch, + has_license, license_spdx_id, last_push_at, last_activity_at, streak_status, + created_at, updated_at + FROM repositories WHERE installation_id = $1 + ORDER BY full_name + `, installationID) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []*Repository + for rows.Next() { + var r Repository + err := rows.Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + repos = append(repos, &r) + } + return repos, nil +} + +func (s *RepositoryStore) ListAll(ctx context.Context, limit, offset int) ([]*RepositoryWithStats, int, error) { + // Get total count + var total int + err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM repositories`).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT + r.id, r.github_id, r.installation_id, r.owner, r.name, r.full_name, r.default_branch, + r.has_license, r.license_spdx_id, r.last_push_at, r.last_activity_at, r.streak_status, + r.created_at, r.updated_at, + COALESCE(a.alert_count, 0) as alerts_count, + COALESCE(c.commit_count, 0) as commits_count + FROM repositories r + LEFT JOIN ( + SELECT repository_id, COUNT(*) as alert_count + FROM alerts + GROUP BY repository_id + ) a ON a.repository_id = r.id + LEFT JOIN ( + SELECT repository_id, COUNT(*) as commit_count + FROM commits + GROUP BY repository_id + ) c ON c.repository_id = r.id + ORDER BY r.full_name + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var repos []*RepositoryWithStats + for rows.Next() { + var r RepositoryWithStats + err := rows.Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + &r.AlertsCount, &r.CommitsCount, + ) + if err != nil { + return nil, 0, err + } + repos = append(repos, &r) + } + return repos, total, nil +} + +func (s *RepositoryStore) GetByID(ctx context.Context, id int64) (*RepositoryWithStats, error) { + var r RepositoryWithStats + err := s.pool.QueryRow(ctx, ` + SELECT + r.id, r.github_id, r.installation_id, r.owner, r.name, r.full_name, r.default_branch, + r.has_license, r.license_spdx_id, r.last_push_at, r.last_activity_at, r.streak_status, + r.created_at, r.updated_at, + COALESCE(a.alert_count, 0) as alerts_count, + COALESCE(c.commit_count, 0) as commits_count + FROM repositories r + LEFT JOIN ( + SELECT repository_id, COUNT(*) as alert_count + FROM alerts + GROUP BY repository_id + ) a ON a.repository_id = r.id + LEFT JOIN ( + SELECT repository_id, COUNT(*) as commit_count + FROM commits + GROUP BY repository_id + ) c ON c.repository_id = r.id + WHERE r.id = $1 + `, id).Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + &r.AlertsCount, &r.CommitsCount, + ) + if err != nil { + return nil, err + } + return &r, nil +} + +func (s *RepositoryStore) ListAtRisk(ctx context.Context, inactivityHours int) ([]*Repository, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, github_id, installation_id, owner, name, full_name, default_branch, + has_license, license_spdx_id, last_push_at, last_activity_at, streak_status, + created_at, updated_at + FROM repositories + WHERE last_activity_at < NOW() - INTERVAL '1 hour' * $1 + AND streak_status = 'active' + ORDER BY last_activity_at + `, inactivityHours) + if err != nil { + return nil, err + } + defer rows.Close() + + var repos []*Repository + for rows.Next() { + var r Repository + err := rows.Scan( + &r.ID, &r.GitHubID, &r.InstallationID, &r.Owner, &r.Name, &r.FullName, + &r.DefaultBranch, &r.HasLicense, &r.LicenseSPDXID, &r.LastPushAt, + &r.LastActivityAt, &r.StreakStatus, &r.CreatedAt, &r.UpdatedAt, + ) + if err != nil { + return nil, err + } + repos = append(repos, &r) + } + return repos, nil +} diff --git a/internal/scorecard/handler.go b/internal/scorecard/handler.go new file mode 100644 index 0000000..7e853e3 --- /dev/null +++ b/internal/scorecard/handler.go @@ -0,0 +1,416 @@ +package scorecard + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/harshpatel5940/gitvigil/internal/database" + "github.com/harshpatel5940/gitvigil/internal/models" + "github.com/rs/zerolog" +) + +type Handler struct { + db *database.DB + logger zerolog.Logger +} + +func NewHandler(db *database.DB, logger zerolog.Logger) *Handler { + return &Handler{ + db: db, + logger: logger.With().Str("component", "scorecard").Logger(), + } +} + +type Scorecard struct { + Repository RepositoryInfo `json:"repository"` + OverallScore int `json:"overall_score"` + OverallStatus string `json:"overall_status"` + Checks []CheckResult `json:"checks"` + Alerts []AlertSummary `json:"alerts"` + Contributors []ContributorStats `json:"contributors"` + ActivitySummary ActivitySummary `json:"activity_summary"` + GeneratedAt time.Time `json:"generated_at"` +} + +type RepositoryInfo struct { + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + HasLicense bool `json:"has_license"` + LicenseID string `json:"license_spdx_id,omitempty"` +} + +type CheckResult struct { + Name string `json:"name"` + Status string `json:"status"` + Score int `json:"score"` + Description string `json:"description"` +} + +type AlertSummary struct { + Type string `json:"type"` + Severity string `json:"severity"` + Count int `json:"count"` + LatestAt time.Time `json:"latest_at,omitempty"` +} + +type ContributorStats struct { + Login string `json:"login"` + TotalCommits int `json:"total_commits"` + Additions int64 `json:"additions"` + Deletions int64 `json:"deletions"` + CommitFrequency float64 `json:"commit_frequency"` + ContributionPattern string `json:"contribution_pattern"` +} + +type ActivitySummary struct { + TotalCommits int `json:"total_commits"` + LastActivityAt time.Time `json:"last_activity_at"` + StreakStatus string `json:"streak_status"` + DaysSinceActivity int `json:"days_since_activity"` + ForcePushCount int `json:"force_push_count"` + BackdateCount int `json:"backdate_count"` +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + repoParam := r.URL.Query().Get("repo") + if repoParam == "" { + http.Error(w, "repo parameter is required (format: owner/name)", http.StatusBadRequest) + return + } + + parts := strings.SplitN(repoParam, "/", 2) + if len(parts) != 2 { + http.Error(w, "invalid repo format, expected owner/name", http.StatusBadRequest) + return + } + owner, name := parts[0], parts[1] + + ctx := r.Context() + + // Get repository + repoStore := models.NewRepositoryStore(h.db.Pool) + repo, err := repoStore.GetByFullName(ctx, owner, name) + if err != nil { + h.logger.Error().Err(err).Str("repo", repoParam).Msg("failed to get repository") + http.Error(w, "repository not found", http.StatusNotFound) + return + } + + // Build scorecard + scorecard, err := h.buildScorecard(ctx, repo) + if err != nil { + h.logger.Error().Err(err).Str("repo", repoParam).Msg("failed to build scorecard") + http.Error(w, "failed to generate scorecard", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(scorecard) +} + +func (h *Handler) buildScorecard(ctx context.Context, repo *models.Repository) (*Scorecard, error) { + commitStore := models.NewCommitStore(h.db.Pool) + alertStore := models.NewAlertStore(h.db.Pool) + contributorStore := models.NewContributorStore(h.db.Pool) + + // Get commit stats + commitStats, err := commitStore.GetStats(ctx, repo.ID) + if err != nil { + return nil, err + } + + // Get alert counts + typeCounts, severityCounts, err := alertStore.CountByRepository(ctx, repo.ID) + if err != nil { + return nil, err + } + + // Get contributors + contributors, err := contributorStore.ListByRepository(ctx, repo.ID) + if err != nil { + return nil, err + } + + // Build checks + checks := h.buildChecks(repo, commitStats, typeCounts) + + // Calculate overall score + overallScore := h.calculateOverallScore(checks) + overallStatus := h.getOverallStatus(overallScore, severityCounts) + + // Build alert summaries + alertSummaries := h.buildAlertSummaries(typeCounts) + + // Build contributor stats + contributorStats := h.buildContributorStats(contributors, commitStats.TotalCommits) + + // Build activity summary + daysSinceActivity := 0 + var lastActivityAt time.Time + if repo.LastActivityAt != nil { + lastActivityAt = *repo.LastActivityAt + daysSinceActivity = int(time.Since(lastActivityAt).Hours() / 24) + } + + forcePushCount := typeCounts[models.AlertForcePush] + backdateCount := typeCounts[models.AlertBackdateSuspicious] + typeCounts[models.AlertBackdateCritical] + + licenseID := "" + if repo.LicenseSPDXID != nil { + licenseID = *repo.LicenseSPDXID + } + + return &Scorecard{ + Repository: RepositoryInfo{ + Owner: repo.Owner, + Name: repo.Name, + FullName: repo.FullName, + HasLicense: repo.HasLicense, + LicenseID: licenseID, + }, + OverallScore: overallScore, + OverallStatus: overallStatus, + Checks: checks, + Alerts: alertSummaries, + Contributors: contributorStats, + ActivitySummary: ActivitySummary{ + TotalCommits: commitStats.TotalCommits, + LastActivityAt: lastActivityAt, + StreakStatus: repo.StreakStatus, + DaysSinceActivity: daysSinceActivity, + ForcePushCount: forcePushCount, + BackdateCount: backdateCount, + }, + GeneratedAt: time.Now(), + }, nil +} + +func (h *Handler) buildChecks(repo *models.Repository, commitStats *models.CommitStats, alertCounts map[models.AlertType]int) []CheckResult { + var checks []CheckResult + + // License check + licenseScore := 0 + licenseStatus := "fail" + licenseDesc := "No license file found" + if repo.HasLicense { + licenseScore = 100 + licenseStatus = "pass" + if repo.LicenseSPDXID != nil { + licenseDesc = "Repository has " + *repo.LicenseSPDXID + " license" + } else { + licenseDesc = "Repository has a license file" + } + } + checks = append(checks, CheckResult{ + Name: "License Present", + Status: licenseStatus, + Score: licenseScore, + Description: licenseDesc, + }) + + // Backdate check + backdateCount := alertCounts[models.AlertBackdateSuspicious] + alertCounts[models.AlertBackdateCritical] + backdateScore := 100 + backdateStatus := "pass" + backdateDesc := "No backdated commits detected" + if backdateCount > 0 { + backdateScore = max(0, 100-backdateCount*20) + if backdateScore < 50 { + backdateStatus = "fail" + } else { + backdateStatus = "warn" + } + backdateDesc = pluralize(backdateCount, "commit", "commits") + " with suspicious timestamps detected" + } + checks = append(checks, CheckResult{ + Name: "No Backdated Commits", + Status: backdateStatus, + Score: backdateScore, + Description: backdateDesc, + }) + + // Force push check + forcePushCount := alertCounts[models.AlertForcePush] + forcePushScore := 100 + forcePushStatus := "pass" + forcePushDesc := "No force pushes detected" + if forcePushCount > 0 { + forcePushScore = max(0, 100-forcePushCount*25) + if forcePushScore < 50 { + forcePushStatus = "fail" + } else { + forcePushStatus = "warn" + } + forcePushDesc = pluralize(forcePushCount, "force push", "force pushes") + " detected" + } + checks = append(checks, CheckResult{ + Name: "No Force Pushes", + Status: forcePushStatus, + Score: forcePushScore, + Description: forcePushDesc, + }) + + // Streak check + streakScore := 100 + streakStatus := "pass" + streakDesc := "Repository has consistent activity" + if repo.StreakStatus == "at_risk" { + streakScore = 50 + streakStatus = "warn" + streakDesc = "Repository activity streak is at risk" + } else if repo.StreakStatus == "inactive" { + streakScore = 0 + streakStatus = "fail" + streakDesc = "Repository has been inactive" + } + checks = append(checks, CheckResult{ + Name: "Activity Streak", + Status: streakStatus, + Score: streakScore, + Description: streakDesc, + }) + + // Conventional commits check + conventionalScore := 0 + conventionalStatus := "warn" + conventionalDesc := "No conventional commits found" + if commitStats.TotalCommits > 0 { + conventionalPct := float64(commitStats.ConventionalCount) / float64(commitStats.TotalCommits) * 100 + conventionalScore = int(conventionalPct) + if conventionalPct >= 80 { + conventionalStatus = "pass" + } else if conventionalPct >= 50 { + conventionalStatus = "warn" + } else { + conventionalStatus = "fail" + } + conventionalDesc = pluralize(int(conventionalPct), "% of commits follow", "% of commits follow") + " conventional format" + } + checks = append(checks, CheckResult{ + Name: "Conventional Commits", + Status: conventionalStatus, + Score: conventionalScore, + Description: conventionalDesc, + }) + + return checks +} + +func (h *Handler) calculateOverallScore(checks []CheckResult) int { + if len(checks) == 0 { + return 0 + } + + total := 0 + for _, check := range checks { + total += check.Score + } + return total / len(checks) +} + +func (h *Handler) getOverallStatus(score int, severityCounts map[models.Severity]int) string { + if severityCounts[models.SeverityCritical] > 0 { + return "critical" + } + if score >= 80 { + return "healthy" + } + if score >= 50 { + return "warning" + } + return "critical" +} + +func (h *Handler) buildAlertSummaries(typeCounts map[models.AlertType]int) []AlertSummary { + var summaries []AlertSummary + + severityMap := map[models.AlertType]string{ + models.AlertBackdateSuspicious: "warning", + models.AlertBackdateCritical: "critical", + models.AlertForcePush: "warning", + models.AlertNoLicense: "info", + models.AlertStreakAtRisk: "warning", + } + + for alertType, count := range typeCounts { + if count > 0 { + summaries = append(summaries, AlertSummary{ + Type: string(alertType), + Severity: severityMap[alertType], + Count: count, + }) + } + } + + return summaries +} + +func (h *Handler) buildContributorStats(contributors []*models.Contributor, totalCommits int) []ContributorStats { + var stats []ContributorStats + + for _, c := range contributors { + login := "" + if c.GitHubLogin != nil { + login = *c.GitHubLogin + } else if c.Name != nil { + login = *c.Name + } else { + login = c.Email + } + + // Calculate commit frequency (commits per day) + var frequency float64 + if c.FirstCommitAt != nil && c.LastCommitAt != nil { + days := c.LastCommitAt.Sub(*c.FirstCommitAt).Hours() / 24 + if days > 0 { + frequency = float64(c.TotalCommits) / days + } else { + frequency = float64(c.TotalCommits) + } + } + + // Determine contribution pattern + pattern := "balanced" + if totalCommits > 0 { + pct := float64(c.TotalCommits) / float64(totalCommits) * 100 + if pct > 80 { + pattern = "lone_wolf" + } + } + + stats = append(stats, ContributorStats{ + Login: login, + TotalCommits: c.TotalCommits, + Additions: c.TotalAdditions, + Deletions: c.TotalDeletions, + CommitFrequency: frequency, + ContributionPattern: pattern, + }) + } + + return stats +} + +func pluralize(n int, singular, plural string) string { + if n == 1 { + return "1 " + singular + } + return fmt.Sprintf("%d %s", n, plural) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..9b08da6 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,134 @@ +package server + +import ( + "context" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/harshpatel5940/gitvigil/internal/api" + "github.com/harshpatel5940/gitvigil/internal/auth" + "github.com/harshpatel5940/gitvigil/internal/config" + "github.com/harshpatel5940/gitvigil/internal/database" + "github.com/harshpatel5940/gitvigil/internal/github" + "github.com/harshpatel5940/gitvigil/internal/scorecard" + "github.com/harshpatel5940/gitvigil/internal/webhook" + "github.com/rs/zerolog" +) + +type Server struct { + cfg *config.Config + db *database.DB + gh *github.AppClient + router *chi.Mux + logger zerolog.Logger +} + +func New(cfg *config.Config, db *database.DB, gh *github.AppClient, logger zerolog.Logger) *Server { + s := &Server{ + cfg: cfg, + db: db, + gh: gh, + router: chi.NewRouter(), + logger: logger, + } + + s.setupMiddleware() + s.setupRoutes() + + return s +} + +func (s *Server) setupMiddleware() { + s.router.Use(middleware.RequestID) + s.router.Use(middleware.RealIP) + s.router.Use(middleware.Recoverer) + s.router.Use(middleware.Timeout(60 * time.Second)) + s.router.Use(s.loggingMiddleware) +} + +func (s *Server) loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + + defer func() { + s.logger.Info(). + Str("method", r.Method). + Str("path", r.URL.Path). + Int("status", ww.Status()). + Dur("duration", time.Since(start)). + Msg("request completed") + }() + + next.ServeHTTP(ww, r) + }) +} + +func (s *Server) setupRoutes() { + s.router.Get("/health", s.handleHealth) + + // Webhook endpoint + webhookHandler := webhook.NewHandler(s.cfg, s.db, s.gh, s.logger) + s.router.Post("/webhook", webhookHandler.ServeHTTP) + + // Scorecard endpoint + scorecardHandler := scorecard.NewHandler(s.db, s.logger) + s.router.Get("/scorecard", scorecardHandler.ServeHTTP) + + // Auth endpoint + authHandler := auth.NewHandler(s.cfg, s.logger) + s.router.Get("/auth/github/callback", authHandler.HandleCallback) + + // API v1 endpoints + apiHandler := api.NewHandler(s.db, s.logger) + s.router.Mount("/api/v1", apiHandler.Router()) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if err := s.db.Health(ctx); err != nil { + s.logger.Error().Err(err).Msg("database health check failed") + http.Error(w, "database unhealthy", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"healthy"}`)) +} + +func (s *Server) Router() *chi.Mux { + return s.router +} + +func (s *Server) Start(ctx context.Context) error { + srv := &http.Server{ + Addr: ":" + s.cfg.Port, + Handler: s.router, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + s.logger.Info().Str("port", s.cfg.Port).Msg("starting server") + + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- err + } + }() + + select { + case <-ctx.Done(): + s.logger.Info().Msg("shutting down server") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } +} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go new file mode 100644 index 0000000..8720ff8 --- /dev/null +++ b/internal/webhook/handler.go @@ -0,0 +1,407 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/google/go-github/v68/github" + "github.com/harshpatel5940/gitvigil/internal/config" + "github.com/harshpatel5940/gitvigil/internal/database" + ghclient "github.com/harshpatel5940/gitvigil/internal/github" + "github.com/rs/zerolog" +) + +type Handler struct { + cfg *config.Config + db *database.DB + gh *ghclient.AppClient + logger zerolog.Logger +} + +func NewHandler(cfg *config.Config, db *database.DB, gh *ghclient.AppClient, logger zerolog.Logger) *Handler { + return &Handler{ + cfg: cfg, + db: db, + gh: gh, + logger: logger.With().Str("component", "webhook").Logger(), + } +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read body + body, err := io.ReadAll(r.Body) + if err != nil { + h.logger.Error().Err(err).Msg("failed to read request body") + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate signature + signature := r.Header.Get("X-Hub-Signature-256") + if h.cfg.WebhookSecret != "" { + if err := ValidateSignature(body, signature, []byte(h.cfg.WebhookSecret)); err != nil { + h.logger.Warn().Err(err).Msg("signature validation failed") + http.Error(w, "invalid signature", http.StatusUnauthorized) + return + } + } + + // Get event type + eventType := r.Header.Get("X-GitHub-Event") + deliveryID := r.Header.Get("X-GitHub-Delivery") + + h.logger.Info(). + Str("event", eventType). + Str("delivery_id", deliveryID). + Msg("received webhook") + + // Record receive time for backdate detection + receiveTime := time.Now() + + // Route event + ctx := r.Context() + switch eventType { + case "push": + h.handlePush(ctx, body, receiveTime) + case "installation": + h.handleInstallation(ctx, body) + case "installation_repositories": + h.handleInstallationRepositories(ctx, body) + case "ping": + h.logger.Info().Msg("received ping event") + default: + h.logger.Debug().Str("event", eventType).Msg("ignoring unhandled event type") + } + + w.WriteHeader(http.StatusOK) +} + +func (h *Handler) handlePush(ctx context.Context, body []byte, receiveTime time.Time) { + var event github.PushEvent + if err := json.Unmarshal(body, &event); err != nil { + h.logger.Error().Err(err).Msg("failed to parse push event") + return + } + + repo := event.GetRepo() + h.logger.Info(). + Str("repo", repo.GetFullName()). + Str("ref", event.GetRef()). + Int("commits", len(event.Commits)). + Bool("forced", event.GetForced()). + Str("pusher", event.GetPusher().GetLogin()). + Msg("processing push event") + + // Store push event + installationID := event.GetInstallation().GetID() + if err := h.storePushEvent(ctx, &event, installationID, receiveTime); err != nil { + h.logger.Error().Err(err).Msg("failed to store push event") + return + } + + // Process commits for backdate detection + for _, commit := range event.Commits { + if err := h.processCommit(ctx, repo, commit, installationID, receiveTime); err != nil { + h.logger.Error(). + Err(err). + Str("sha", commit.GetID()). + Msg("failed to process commit") + } + } + + // Check for force push + if event.GetForced() { + h.createForcePushAlert(ctx, repo, &event) + } +} + +func (h *Handler) handleInstallation(ctx context.Context, body []byte) { + var event github.InstallationEvent + if err := json.Unmarshal(body, &event); err != nil { + h.logger.Error().Err(err).Msg("failed to parse installation event") + return + } + + action := event.GetAction() + installation := event.GetInstallation() + account := installation.GetAccount() + + h.logger.Info(). + Str("action", action). + Int64("installation_id", installation.GetID()). + Str("account", account.GetLogin()). + Msg("processing installation event") + + switch action { + case "created": + if err := h.storeInstallation(ctx, installation); err != nil { + h.logger.Error().Err(err).Msg("failed to store installation") + } + // Store repositories + for _, repo := range event.Repositories { + if err := h.storeRepository(ctx, repo, installation.GetID()); err != nil { + h.logger.Error().Err(err).Str("repo", repo.GetFullName()).Msg("failed to store repository") + } + } + case "deleted": + // Clean up installation data + h.logger.Info().Int64("installation_id", installation.GetID()).Msg("installation deleted") + } +} + +func (h *Handler) handleInstallationRepositories(ctx context.Context, body []byte) { + var event github.InstallationRepositoriesEvent + if err := json.Unmarshal(body, &event); err != nil { + h.logger.Error().Err(err).Msg("failed to parse installation_repositories event") + return + } + + installationID := event.GetInstallation().GetID() + + // Handle added repositories + for _, repo := range event.RepositoriesAdded { + if err := h.storeRepository(ctx, repo, installationID); err != nil { + h.logger.Error().Err(err).Str("repo", repo.GetFullName()).Msg("failed to store added repository") + } + } + + // Handle removed repositories + for _, repo := range event.RepositoriesRemoved { + h.logger.Info().Str("repo", repo.GetFullName()).Msg("repository removed from installation") + } +} + +func (h *Handler) storePushEvent(ctx context.Context, event *github.PushEvent, installationID int64, receiveTime time.Time) error { + repo := event.GetRepo() + + // First ensure repository exists + _, err := h.db.Pool.Exec(ctx, ` + INSERT INTO repositories (github_id, installation_id, owner, name, full_name, last_push_at, last_activity_at) + VALUES ($1, $2, $3, $4, $5, $6, $6) + ON CONFLICT (github_id) DO UPDATE SET + last_push_at = $6, + last_activity_at = $6, + streak_status = 'active', + updated_at = NOW() + `, repo.GetID(), installationID, repo.GetOwner().GetLogin(), repo.GetName(), repo.GetFullName(), receiveTime) + if err != nil { + return err + } + + // Get repository ID + var repoID int64 + err = h.db.Pool.QueryRow(ctx, `SELECT id FROM repositories WHERE github_id = $1`, repo.GetID()).Scan(&repoID) + if err != nil { + return err + } + + // Store push event + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO push_events (repository_id, push_id, ref, before_sha, after_sha, forced, pusher_login, commit_count, distinct_count, received_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, repoID, event.GetPushID(), event.GetRef(), event.GetBefore(), event.GetAfter(), + event.GetForced(), event.GetPusher().GetLogin(), len(event.Commits), event.GetDistinctSize(), receiveTime) + + return err +} + +func (h *Handler) processCommit(ctx context.Context, repo *github.PushEventRepository, commit *github.HeadCommit, installationID int64, receiveTime time.Time) error { + // Get repository ID + var repoID int64 + err := h.db.Pool.QueryRow(ctx, `SELECT id FROM repositories WHERE github_id = $1`, repo.GetID()).Scan(&repoID) + if err != nil { + return err + } + + // Get commit author date + authorDate := commit.GetTimestamp().Time + + // Calculate backdate hours + backdateHours := int(receiveTime.Sub(authorDate).Hours()) + isBackdated := backdateHours > h.cfg.BackdateSuspiciousHours + + // Determine conventional commit type + isConventional, conventionalType, conventionalScope := parseConventionalCommit(commit.GetMessage()) + + // Store commit + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO commits (repository_id, sha, message, author_email, author_name, author_date, committer_date, pushed_at, additions, deletions, is_conventional, conventional_type, conventional_scope, is_backdated, backdate_hours) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (sha) DO NOTHING + `, repoID, commit.GetID(), commit.GetMessage(), + commit.GetAuthor().GetEmail(), commit.GetAuthor().GetName(), + authorDate, commit.GetTimestamp().Time, receiveTime, + 0, 0, // additions/deletions not available in push event + isConventional, conventionalType, conventionalScope, + isBackdated, backdateHours) + if err != nil { + return err + } + + // Create backdate alert if needed + if isBackdated { + severity := "warning" + alertType := "backdate_suspicious" + if backdateHours > h.cfg.BackdateCriticalHours { + severity = "critical" + alertType = "backdate_critical" + } + + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO alerts (repository_id, commit_sha, alert_type, severity, title, description, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, repoID, commit.GetID(), alertType, severity, + "Backdated commit detected", + "Commit author date is significantly older than push time", + map[string]interface{}{ + "author_date": authorDate, + "pushed_at": receiveTime, + "backdate_hours": backdateHours, + }) + if err != nil { + h.logger.Error().Err(err).Msg("failed to create backdate alert") + } + } + + // Update contributor stats + if err := h.updateContributor(ctx, repoID, commit, receiveTime); err != nil { + h.logger.Error().Err(err).Msg("failed to update contributor") + } + + return nil +} + +func (h *Handler) updateContributor(ctx context.Context, repoID int64, commit *github.HeadCommit, receiveTime time.Time) error { + author := commit.GetAuthor() + + _, err := h.db.Pool.Exec(ctx, ` + INSERT INTO contributors (repository_id, github_login, email, name, total_commits, first_commit_at, last_commit_at) + VALUES ($1, $2, $3, $4, 1, $5, $5) + ON CONFLICT (repository_id, email) DO UPDATE SET + github_login = COALESCE(EXCLUDED.github_login, contributors.github_login), + name = COALESCE(EXCLUDED.name, contributors.name), + total_commits = contributors.total_commits + 1, + last_commit_at = $5, + updated_at = NOW() + `, repoID, author.GetLogin(), author.GetEmail(), author.GetName(), receiveTime) + + return err +} + +func (h *Handler) createForcePushAlert(ctx context.Context, repo *github.PushEventRepository, event *github.PushEvent) { + var repoID int64 + err := h.db.Pool.QueryRow(ctx, `SELECT id FROM repositories WHERE github_id = $1`, repo.GetID()).Scan(&repoID) + if err != nil { + h.logger.Error().Err(err).Msg("failed to get repository ID for force push alert") + return + } + + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO alerts (repository_id, alert_type, severity, title, description, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + `, repoID, "force_push", "warning", + "Force push detected", + "Repository history was rewritten", + map[string]interface{}{ + "ref": event.GetRef(), + "before": event.GetBefore(), + "after": event.GetAfter(), + "pusher": event.GetPusher().GetLogin(), + }) + if err != nil { + h.logger.Error().Err(err).Msg("failed to create force push alert") + } +} + +func (h *Handler) storeInstallation(ctx context.Context, installation *github.Installation) error { + account := installation.GetAccount() + _, err := h.db.Pool.Exec(ctx, ` + INSERT INTO installations (installation_id, account_login, account_type) + VALUES ($1, $2, $3) + ON CONFLICT (installation_id) DO UPDATE SET + account_login = $2, + updated_at = NOW() + `, installation.GetID(), account.GetLogin(), account.GetType()) + return err +} + +func (h *Handler) storeRepository(ctx context.Context, repo *github.Repository, installationID int64) error { + _, err := h.db.Pool.Exec(ctx, ` + INSERT INTO repositories (github_id, installation_id, owner, name, full_name) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (github_id) DO UPDATE SET + installation_id = $2, + updated_at = NOW() + `, repo.GetID(), installationID, repo.GetOwner().GetLogin(), repo.GetName(), repo.GetFullName()) + return err +} + +func parseConventionalCommit(message string) (bool, string, string) { + // Simple conventional commit parser + // Format: type(scope): description or type: description + if len(message) == 0 { + return false, "", "" + } + + // Find the colon + colonIdx := -1 + for i, c := range message { + if c == ':' { + colonIdx = i + break + } + if c == '\n' { + break + } + } + + if colonIdx == -1 || colonIdx == 0 { + return false, "", "" + } + + prefix := message[:colonIdx] + + // Check for scope + var commitType, scope string + if parenStart := indexOf(prefix, '('); parenStart != -1 { + if parenEnd := indexOf(prefix, ')'); parenEnd > parenStart { + commitType = prefix[:parenStart] + scope = prefix[parenStart+1 : parenEnd] + } else { + return false, "", "" + } + } else { + commitType = prefix + } + + // Validate type + validTypes := map[string]bool{ + "feat": true, "fix": true, "docs": true, "style": true, + "refactor": true, "perf": true, "test": true, "build": true, + "ci": true, "chore": true, "revert": true, + } + + if !validTypes[commitType] { + return false, "", "" + } + + return true, commitType, scope +} + +func indexOf(s string, c rune) int { + for i, r := range s { + if r == c { + return i + } + } + return -1 +} diff --git a/internal/webhook/signature.go b/internal/webhook/signature.go new file mode 100644 index 0000000..bf8770a --- /dev/null +++ b/internal/webhook/signature.go @@ -0,0 +1,41 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" +) + +var ( + ErrMissingSignature = errors.New("missing X-Hub-Signature-256 header") + ErrInvalidSignature = errors.New("invalid signature") + ErrSignatureMismatch = errors.New("signature mismatch") +) + +func ValidateSignature(payload []byte, signature string, secret []byte) error { + if signature == "" { + return ErrMissingSignature + } + + if !strings.HasPrefix(signature, "sha256=") { + return ErrInvalidSignature + } + + signatureHex := strings.TrimPrefix(signature, "sha256=") + signatureBytes, err := hex.DecodeString(signatureHex) + if err != nil { + return ErrInvalidSignature + } + + mac := hmac.New(sha256.New, secret) + mac.Write(payload) + expected := mac.Sum(nil) + + if !hmac.Equal(expected, signatureBytes) { + return ErrSignatureMismatch + } + + return nil +}