From f725a8da8a7b901923bd4a047a85b639976a59e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:57:58 +0000 Subject: [PATCH] Add dynamic Python backend with FastAPI, cloud hosting configs, tests, and docs Agent-Logs-Url: https://github.com/NextCommunity/.github/sessions/55ba2c5a-a966-4ddb-908a-a990cb40869b Co-authored-by: jbampton <418747+jbampton@users.noreply.github.com> --- .github/workflows/deploy-backend.yml | 55 ++++++ README.md | 5 + backend/.dockerignore | 17 ++ backend/Dockerfile | 31 +++ backend/README.md | 174 ++++++++++++++++ backend/app/__init__.py | 0 backend/app/config.py | 28 +++ backend/app/main.py | 106 ++++++++++ backend/app/models/__init__.py | 0 backend/app/models/contributor.py | 55 ++++++ backend/app/models/leaderboard.py | 79 ++++++++ backend/app/routers/__init__.py | 0 backend/app/routers/contributors.py | 48 +++++ backend/app/routers/leaderboard.py | 81 ++++++++ backend/app/routers/stats.py | 94 +++++++++ backend/app/services/__init__.py | 0 backend/app/services/achievements.py | 77 ++++++++ backend/app/services/cache.py | 52 +++++ backend/app/services/github_client.py | 71 +++++++ backend/app/services/leaderboard.py | 263 ++++++++++++++++++++++++ backend/app/services/levels.py | 182 +++++++++++++++++ backend/deploy/cloudbuild.yaml | 32 +++ backend/deploy/fly.toml | 24 +++ backend/deploy/lambda_handler.py | 11 ++ backend/deploy/leaderboard-api.service | 18 ++ backend/deploy/nginx.conf | 29 +++ backend/deploy/railway.toml | 8 + backend/deploy/render.yaml | 18 ++ backend/deploy/template.yaml | 43 ++++ backend/docker-compose.yml | 20 ++ backend/requirements.txt | 15 ++ backend/tests/__init__.py | 0 backend/tests/test_api.py | 169 ++++++++++++++++ backend/tests/test_leaderboard.py | 264 +++++++++++++++++++++++++ 34 files changed, 2069 insertions(+) create mode 100644 .github/workflows/deploy-backend.yml create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/contributor.py create mode 100644 backend/app/models/leaderboard.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/contributors.py create mode 100644 backend/app/routers/leaderboard.py create mode 100644 backend/app/routers/stats.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/achievements.py create mode 100644 backend/app/services/cache.py create mode 100644 backend/app/services/github_client.py create mode 100644 backend/app/services/leaderboard.py create mode 100644 backend/app/services/levels.py create mode 100644 backend/deploy/cloudbuild.yaml create mode 100644 backend/deploy/fly.toml create mode 100644 backend/deploy/lambda_handler.py create mode 100644 backend/deploy/leaderboard-api.service create mode 100644 backend/deploy/nginx.conf create mode 100644 backend/deploy/railway.toml create mode 100644 backend/deploy/render.yaml create mode 100644 backend/deploy/template.yaml create mode 100644 backend/docker-compose.yml create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_leaderboard.py diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml new file mode 100644 index 0000000..73db6d2 --- /dev/null +++ b/.github/workflows/deploy-backend.yml @@ -0,0 +1,55 @@ +name: Backend CI/CD + +on: + push: + branches: [main] + paths: + - "backend/**" + pull_request: + branches: [main] + paths: + - "backend/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -r backend/requirements.txt + + - name: Run tests + run: python -m pytest backend/tests/ -v --tb=short + + docker-build: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build Docker image + run: docker build -f backend/Dockerfile -t nextcommunity-leaderboard-api . + + - name: Verify container health + run: | + docker run -d --name api-test -p 8000:8000 nextcommunity-leaderboard-api + sleep 5 + curl -f http://localhost:8000/health || exit 1 + docker stop api-test diff --git a/README.md b/README.md index 144b244..9601797 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ # Welcome https://github.com/NextCommunity/.github/blob/main/profile/README.md + +## Backend API + +A dynamic FastAPI backend serves the leaderboard data as a JSON API with cloud hosting support. +See [`backend/README.md`](backend/README.md) for setup, endpoints, and deployment guides. diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..9bac156 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +profile +__pycache__ +*.pyc +*.pyo +.env +.venv +venv +*.egg-info +.pytest_cache +.mypy_cache +.ruff_cache +htmlcov +.coverage +*.md +LICENSE diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..866f72e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,31 @@ +# ---------- build stage ---------- +FROM python:3.13-slim AS builder + +WORKDIR /build +COPY backend/requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +# ---------- runtime stage ---------- +FROM python:3.13-slim + +LABEL maintainer="NextCommunity" \ + description="NextCommunity Leaderboard API" + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /install /usr/local + +# Copy application code +COPY backend/ ./backend/ + +# Non-root user for security +RUN useradd --create-home appuser +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" + +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..823a39d --- /dev/null +++ b/backend/README.md @@ -0,0 +1,174 @@ +# NextCommunity Leaderboard Backend + +A dynamic Python API that serves the [NextCommunity](https://github.com/NextCommunity) organization leaderboard with gamified levels, achievements, points, and streaks. + +## Features + +- **Real-time leaderboard API** — JSON endpoints for contributor rankings +- **Contributor profiles** — individual stats with level, achievements, streak, points +- **Aggregate statistics** — org-wide metrics and rarity distribution +- **Level & achievement catalogs** — complete gamification reference data +- **Automatic caching** — TTL-based in-memory cache to respect GitHub API rate limits +- **API key protection** — optional authentication for write endpoints +- **Cloud-ready** — configurations for Railway, Render, Fly.io, AWS Lambda, Google Cloud Run, and self-hosted + +## Quick Start + +### Prerequisites + +- Python 3.13+ +- A [GitHub personal access token](https://github.com/settings/tokens) (recommended for higher API rate limits) + +### Local Development + +```bash +# Install dependencies +cd backend +pip install -r requirements.txt + +# Set environment variables +export GITHUB_TOKEN="ghp_your_token_here" +export ORG_NAME="NextCommunity" + +# Run the server +cd .. # back to repo root +uvicorn backend.app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +The API will be available at `http://localhost:8000`. Interactive docs are at `http://localhost:8000/docs`. + +### Docker + +```bash +# Build and run +docker compose -f backend/docker-compose.yml up --build + +# Or build manually +docker build -f backend/Dockerfile -t nextcommunity-api . +docker run -p 8000:8000 -e GITHUB_TOKEN=ghp_... nextcommunity-api +``` + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/health` | Health check | +| `GET` | `/api/leaderboard` | Full leaderboard with all contributors | +| `GET` | `/api/contributors/{login}` | Individual contributor profile | +| `GET` | `/api/stats` | Aggregate org-wide statistics | +| `GET` | `/api/levels` | All level definitions | +| `GET` | `/api/achievements` | Achievement catalog | +| `POST` | `/api/refresh` | Force cache refresh (API key required when configured) | + +Full OpenAPI documentation is available at `/docs` (Swagger UI) and `/redoc` (ReDoc). + +## Configuration + +All configuration is via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `GITHUB_TOKEN` | *(empty)* | GitHub personal access token | +| `ORG_NAME` | `NextCommunity` | GitHub organization name | +| `CACHE_TTL` | `900` | Cache time-to-live in seconds (15 min) | +| `API_KEY` | *(empty)* | API key for protected endpoints (empty = open) | +| `HOST` | `0.0.0.0` | Server bind address | +| `PORT` | `8000` | Server port | +| `LOG_LEVEL` | `info` | Logging level | +| `LEVELS_JSON_URL` | *(canonical URL)* | URL for level definitions JSON | + +## Cloud Deployment + +Pre-built configurations are provided in the `deploy/` directory: + +### Railway (Recommended) + +1. Connect your GitHub repo to [Railway](https://railway.app) +2. Set environment variables in the Railway dashboard +3. Railway auto-detects the `deploy/railway.toml` configuration + +### Render + +1. Create a new Web Service on [Render](https://render.com) +2. Use the `deploy/render.yaml` blueprint or configure manually +3. Set environment variables in the Render dashboard + +### Fly.io + +```bash +fly launch --config backend/deploy/fly.toml +fly secrets set GITHUB_TOKEN=ghp_... +fly deploy +``` + +### AWS Lambda (Serverless) + +1. Install [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) +2. Uncomment `mangum` in `requirements.txt` +3. Deploy: + +```bash +cd backend/deploy +sam build --template template.yaml +sam deploy --guided +``` + +### Google Cloud Run + +```bash +gcloud builds submit --config backend/deploy/cloudbuild.yaml . +``` + +### Self-hosted (VPS) + +1. Copy the repository to your server +2. Set up the systemd service: `sudo cp backend/deploy/leaderboard-api.service /etc/systemd/system/` +3. Configure Nginx: `sudo cp backend/deploy/nginx.conf /etc/nginx/sites-available/leaderboard` +4. Create a `.env` file and enable the service: + +```bash +sudo systemctl enable --now leaderboard-api +sudo systemctl reload nginx +``` + +## Testing + +```bash +# Run all tests +python -m pytest backend/tests/ -v + +# Run specific test file +python -m pytest backend/tests/test_leaderboard.py -v +python -m pytest backend/tests/test_api.py -v +``` + +## Architecture + +``` +backend/ +├── app/ +│ ├── main.py # FastAPI application entry point +│ ├── config.py # Environment-based settings +│ ├── routers/ +│ │ ├── leaderboard.py # GET /api/leaderboard, POST /api/refresh +│ │ ├── contributors.py # GET /api/contributors/{login} +│ │ └── stats.py # GET /api/stats, /api/levels, /api/achievements +│ ├── services/ +│ │ ├── github_client.py # Async GitHub API client (httpx) +│ │ ├── leaderboard.py # Core leaderboard aggregation logic +│ │ ├── levels.py # Level computation, rarity, progress bars +│ │ ├── achievements.py # Achievement definitions and evaluation +│ │ └── cache.py # TTL-based in-memory cache +│ └── models/ +│ ├── contributor.py # Pydantic models for contributor data +│ └── leaderboard.py # Response schemas +├── deploy/ # Cloud hosting configurations +├── tests/ # Unit and integration tests +├── requirements.txt # Python dependencies +├── Dockerfile # Multi-stage container build +└── docker-compose.yml # Local development setup +``` + +## Relationship to the Leaderboard Script + +The existing `scripts/leaderboard.py` continues to run as a GitHub Action to update the static `profile/README.md`. This backend provides the same data through a dynamic API. Both systems share the same gamification logic and level definitions. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..3398696 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,28 @@ +"""Application configuration loaded from environment variables.""" + +import os + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Backend configuration. + + All values can be overridden via environment variables. + """ + + org_name: str = os.getenv("ORG_NAME", "NextCommunity") + github_token: str = os.getenv("GITHUB_TOKEN", "") + api_key: str = os.getenv("API_KEY", "") + cache_ttl: int = int(os.getenv("CACHE_TTL", "900")) # 15 minutes + host: str = os.getenv("HOST", "0.0.0.0") + port: int = int(os.getenv("PORT", "8000")) + log_level: str = os.getenv("LOG_LEVEL", "info") + levels_json_url: str = os.getenv( + "LEVELS_JSON_URL", + "https://raw.githubusercontent.com/NextCommunity/" + "NextCommunity.github.io/main/src/_data/levels.json", + ) + + +settings = Settings() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..75163a7 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,106 @@ +"""NextCommunity Leaderboard Backend — FastAPI application.""" + +from __future__ import annotations + +import hmac +import logging +import time + +from fastapi import Depends, FastAPI, HTTPException, Request, Response, Security +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import APIKeyHeader + +from backend.app.config import settings +from backend.app.models.leaderboard import HealthResponse +from backend.app.routers import contributors, leaderboard, stats + +logger = logging.getLogger("backend") + +app = FastAPI( + title="NextCommunity Leaderboard API", + description=( + "Dynamic API serving the NextCommunity organization leaderboard " + "with gamified levels, achievements, points, and streaks." + ), + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", +) + +# --- CORS --- +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# --- Request timing middleware --- + + +@app.middleware("http") +async def add_timing_header(request: Request, call_next): + start = time.monotonic() + response: Response = await call_next(request) + elapsed = time.monotonic() - start + response.headers["X-Process-Time"] = f"{elapsed:.3f}" + return response + + +# --- API key security for protected endpoints --- + +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def verify_api_key( + api_key: str | None = Security(_api_key_header), +) -> str: + """Validate the API key for protected endpoints.""" + if not settings.api_key: + # No key configured — allow all (development mode) + return "" + if not api_key or not hmac.compare_digest(api_key, settings.api_key): + raise HTTPException(status_code=403, detail="Invalid or missing API key") + return api_key + + +# --- Routers --- +app.include_router(leaderboard.router) +app.include_router(contributors.router) +app.include_router(stats.router) + + +# Override the refresh endpoint to require API key when configured +@app.post( + "/api/refresh", + response_model=leaderboard.RefreshResponse, + tags=["leaderboard"], + summary="Refresh leaderboard data (requires API key when configured)", + include_in_schema=True, +) +async def refresh_with_auth( + _key: str = Depends(verify_api_key), +): + return await leaderboard.refresh_leaderboard() + + +# Remove the unprotected refresh route added by the router +for route in app.routes: + if hasattr(route, "path") and route.path == "/api/refresh" and hasattr(route, "endpoint"): + if route.endpoint is not refresh_with_auth: + app.routes.remove(route) + break + + +# --- Health endpoint --- + + +@app.get( + "/health", + response_model=HealthResponse, + tags=["system"], + summary="Health check", +) +async def health() -> HealthResponse: + return HealthResponse(status="healthy", version="1.0.0") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/contributor.py b/backend/app/models/contributor.py new file mode 100644 index 0000000..32999e3 --- /dev/null +++ b/backend/app/models/contributor.py @@ -0,0 +1,55 @@ +"""Pydantic models for contributor data.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class AchievementBadge(BaseModel): + """A single earned achievement badge.""" + + emoji: str = Field(..., description="Achievement emoji") + label: str = Field(..., description="Achievement name") + + +class ContributorSummary(BaseModel): + """Abbreviated contributor data for the leaderboard table.""" + + rank: int = Field(..., description="Leaderboard rank") + login: str = Field(..., description="GitHub login") + commits: int = Field(..., description="Total commits") + authored_commits: int = Field(..., description="Authored commits") + coauthored_commits: int = Field(..., description="Co-authored commits") + level_num: int = Field(..., description="Current level number") + level_emoji: str = Field(..., description="Level emoji") + level_title: str = Field(..., description="Level title") + level_rarity: str = Field(..., description="Current level rarity tier") + peak_rarity: str = Field(..., description="Highest rarity tier reached") + longest_streak: int = Field(..., description="Longest commit streak in days") + repos_count: int = Field(..., description="Number of repositories contributed to") + achievement_count: int = Field(..., description="Number of achievements earned") + points: int = Field(..., description="Gamified point total") + + +class ContributorDetail(BaseModel): + """Full contributor profile with all gamification data.""" + + login: str + commits: int + authored_commits: int + coauthored_commits: int + site_commits: int = Field(..., description="Commits to the site repo") + dotgithub_commits: int = Field(..., description="Commits to the .github repo") + repos_count: int + repo_names: list[str] = Field(default_factory=list, description="Repository names") + longest_streak: int + level_num: int + level_emoji: str + level_title: str + level_rarity: str + level_description: str = "" + level_color: str = "#94a3b8" + peak_rarity: str + achievements: list[AchievementBadge] + points: int + progress: str = Field("", description="Progress bar toward next milestone") diff --git a/backend/app/models/leaderboard.py b/backend/app/models/leaderboard.py new file mode 100644 index 0000000..b16d7ba --- /dev/null +++ b/backend/app/models/leaderboard.py @@ -0,0 +1,79 @@ +"""Pydantic models for leaderboard and stats responses.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from backend.app.models.contributor import ContributorSummary + + +class LeaderboardResponse(BaseModel): + """Full leaderboard response.""" + + total_contributors: int = Field(..., description="Total number of contributors") + had_errors: bool = Field(..., description="Whether any API errors occurred during fetch") + contributors: list[ContributorSummary] + + +class StatsResponse(BaseModel): + """Aggregate org-wide statistics.""" + + total_contributors: int + total_commits: int + total_authored: int + total_coauthored: int + total_achievements: int + average_commits: float + average_points: float + top_rarity: str = Field(..., description="Highest rarity achieved across all contributors") + rarity_distribution: dict[str, int] = Field( + ..., description="Number of contributors at each rarity tier" + ) + + +class LevelEntry(BaseModel): + """A single level definition.""" + + level: int + name: str + emoji: str + rarity: str = "common" + description: str = "" + color: str = "#94a3b8" + + +class LevelsResponse(BaseModel): + """Level definitions response.""" + + total_levels: int + levels: list[LevelEntry] + + +class AchievementDefinition(BaseModel): + """A single achievement definition.""" + + emoji: str + label: str + description: str + + +class AchievementsResponse(BaseModel): + """Achievement catalog response.""" + + total_achievements: int + achievements: list[AchievementDefinition] + + +class RefreshResponse(BaseModel): + """Response after a cache refresh.""" + + status: str = "ok" + contributors_count: int = 0 + message: str = "" + + +class HealthResponse(BaseModel): + """Health check response.""" + + status: str = "healthy" + version: str = "1.0.0" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/contributors.py b/backend/app/routers/contributors.py new file mode 100644 index 0000000..153296c --- /dev/null +++ b/backend/app/routers/contributors.py @@ -0,0 +1,48 @@ +"""Contributor detail API endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from backend.app.models.contributor import AchievementBadge, ContributorDetail +from backend.app.routers.leaderboard import _get_cached_leaderboard +from backend.app.services.levels import progress_bar + +router = APIRouter(prefix="/api", tags=["contributors"]) + + +@router.get( + "/contributors/{login}", + response_model=ContributorDetail, + summary="Get a contributor profile", + description="Returns full gamification data for a single contributor.", +) +async def get_contributor(login: str) -> ContributorDetail: + contributors, _had_errors, _levels = await _get_cached_leaderboard() + for c in contributors: + if c["login"].lower() == login.lower(): + return ContributorDetail( + login=c["login"], + commits=c["commits"], + authored_commits=c["authored_commits"], + coauthored_commits=c["coauthored_commits"], + site_commits=c["site_commits"], + dotgithub_commits=c["dotgithub_commits"], + repos_count=c["repos_count"], + repo_names=c.get("repo_names", []), + longest_streak=c["longest_streak"], + level_num=c["level_num"], + level_emoji=c["level_emoji"], + level_title=c["level_title"], + level_rarity=c["level_rarity"], + level_description=c.get("level_description", ""), + level_color=c.get("level_color", "#94a3b8"), + peak_rarity=c["peak_rarity"], + achievements=[ + AchievementBadge(emoji=e, label=l) + for e, l in c["achievements"] + ], + points=c["points"], + progress=progress_bar(c["commits"]), + ) + raise HTTPException(status_code=404, detail=f"Contributor '{login}' not found") diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py new file mode 100644 index 0000000..f893342 --- /dev/null +++ b/backend/app/routers/leaderboard.py @@ -0,0 +1,81 @@ +"""Leaderboard API endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from backend.app.models.contributor import AchievementBadge, ContributorSummary +from backend.app.models.leaderboard import ( + LeaderboardResponse, + RefreshResponse, +) +from backend.app.services.cache import cache +from backend.app.services.leaderboard import build_leaderboard +from backend.app.services.levels import progress_bar + +router = APIRouter(prefix="/api", tags=["leaderboard"]) + +CACHE_KEY = "leaderboard" + + +async def _get_cached_leaderboard() -> tuple[list[dict], bool, list[dict]]: + """Return cached leaderboard data, rebuilding if stale.""" + cached = await cache.get(CACHE_KEY) + if cached is not None: + return cached + result = await build_leaderboard() + await cache.set(CACHE_KEY, result) + return result + + +@router.get( + "/leaderboard", + response_model=LeaderboardResponse, + summary="Get the full leaderboard", + description="Returns all contributors ranked by commit count with gamification data.", +) +async def get_leaderboard() -> LeaderboardResponse: + contributors, had_errors, _levels = await _get_cached_leaderboard() + summaries = [ + ContributorSummary( + rank=i, + login=c["login"], + commits=c["commits"], + authored_commits=c["authored_commits"], + coauthored_commits=c["coauthored_commits"], + level_num=c["level_num"], + level_emoji=c["level_emoji"], + level_title=c["level_title"], + level_rarity=c["level_rarity"], + peak_rarity=c["peak_rarity"], + longest_streak=c["longest_streak"], + repos_count=c["repos_count"], + achievement_count=len(c["achievements"]), + points=c["points"], + ) + for i, c in enumerate(contributors, start=1) + ] + return LeaderboardResponse( + total_contributors=len(contributors), + had_errors=had_errors, + contributors=summaries, + ) + + +@router.post( + "/refresh", + response_model=RefreshResponse, + summary="Refresh leaderboard data", + description="Force re-fetch from GitHub API and rebuild the leaderboard cache.", +) +async def refresh_leaderboard() -> RefreshResponse: + await cache.invalidate(CACHE_KEY) + contributors, had_errors, _levels = await _get_cached_leaderboard() + msg = "Leaderboard refreshed successfully" + if had_errors: + msg += " (some repos had API errors)" + return RefreshResponse( + status="ok", + contributors_count=len(contributors), + message=msg, + ) diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..fb3d32b --- /dev/null +++ b/backend/app/routers/stats.py @@ -0,0 +1,94 @@ +"""Statistics and metadata API endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from backend.app.models.leaderboard import ( + AchievementDefinition, + AchievementsResponse, + LevelEntry, + LevelsResponse, + StatsResponse, +) +from backend.app.routers.leaderboard import _get_cached_leaderboard +from backend.app.services.achievements import ACHIEVEMENTS +from backend.app.services.levels import RARITY_ORDER, RARITY_RANK + +router = APIRouter(prefix="/api", tags=["stats"]) + + +@router.get( + "/stats", + response_model=StatsResponse, + summary="Aggregate organization statistics", + description="Returns org-wide aggregate numbers: commits, contributors, achievements, rarity distribution.", +) +async def get_stats() -> StatsResponse: + contributors, _had_errors, _levels = await _get_cached_leaderboard() + + total_commits = sum(c["commits"] for c in contributors) + total_authored = sum(c["authored_commits"] for c in contributors) + total_coauthored = sum(c["coauthored_commits"] for c in contributors) + total_achievements = sum(len(c["achievements"]) for c in contributors) + n = len(contributors) or 1 + + # Rarity distribution + rarity_dist: dict[str, int] = {r: 0 for r in RARITY_ORDER} + top_rarity = "common" + top_rank = 0 + for c in contributors: + rarity = c.get("peak_rarity", "common") + rarity_dist[rarity] = rarity_dist.get(rarity, 0) + 1 + rank = RARITY_RANK.get(rarity, 0) + if rank > top_rank: + top_rarity = rarity + top_rank = rank + + return StatsResponse( + total_contributors=len(contributors), + total_commits=total_commits, + total_authored=total_authored, + total_coauthored=total_coauthored, + total_achievements=total_achievements, + average_commits=round(total_commits / n, 1), + average_points=round(sum(c["points"] for c in contributors) / n, 1), + top_rarity=top_rarity, + rarity_distribution=rarity_dist, + ) + + +@router.get( + "/levels", + response_model=LevelsResponse, + summary="Level definitions", + description="Returns all level definitions with rarity tiers.", +) +async def get_levels() -> LevelsResponse: + _contributors, _had_errors, levels_data = await _get_cached_leaderboard() + entries = [ + LevelEntry( + level=lv.get("level", 0), + name=lv.get("name", ""), + emoji=lv.get("emoji", ""), + rarity=lv.get("rarity", "common"), + description=lv.get("description", ""), + color=lv.get("color", "#94a3b8"), + ) + for lv in levels_data + ] + return LevelsResponse(total_levels=len(entries), levels=entries) + + +@router.get( + "/achievements", + response_model=AchievementsResponse, + summary="Achievement catalog", + description="Returns all possible achievements with their descriptions.", +) +async def get_achievements() -> AchievementsResponse: + defs = [ + AchievementDefinition(emoji=emoji, label=label, description=desc) + for emoji, label, desc, _check in ACHIEVEMENTS + ] + return AchievementsResponse(total_achievements=len(defs), achievements=defs) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/achievements.py b/backend/app/services/achievements.py new file mode 100644 index 0000000..169d024 --- /dev/null +++ b/backend/app/services/achievements.py @@ -0,0 +1,77 @@ +"""Achievement system service. + +Mirrors the achievement definitions from ``scripts/leaderboard.py``. +""" + +from __future__ import annotations + +from backend.app.services.levels import RARITY_RANK + + +def _peak_rarity_rank(contrib: dict) -> int: + """Return the numeric rank for a contributor's peak rarity.""" + return RARITY_RANK.get( + contrib.get("peak_rarity", contrib.get("level_rarity")), 0, + ) + + +# Each entry is (emoji, label, description, check_function). +ACHIEVEMENTS: list[tuple[str, str, str, object]] = [ + ("🎯", "First Commit", "Make your first contribution", + lambda c: c["commits"] >= 1), + ("✋", "High Five", "Reach 5 commits", + lambda c: c["commits"] >= 5), + ("🌟", "Rising Star", "Reach 25 commits", + lambda c: c["commits"] >= 25), + ("🌐", "Explorer", "Contribute to 2+ repositories", + lambda c: c["repos_count"] >= 2), + ("🏗️", "Architect", "Contribute to 3+ repositories", + lambda c: c["repos_count"] >= 3), + ("💪", "Dedicated", "Reach 50 commits", + lambda c: c["commits"] >= 50), + ("🚀", "Rockstar", "Reach 100 commits", + lambda c: c["commits"] >= 100), + ("🏅", "Quarter Master", "Reach 250 commits", + lambda c: c["commits"] >= 250), + ("⭐", "Superstar", "Reach 500 commits", + lambda c: c["commits"] >= 500), + ("👑", "Elite", "Reach 750 commits", + lambda c: c["commits"] >= 750), + ("🏆", "Thousand Club", "Reach 1000 commits", + lambda c: c["commits"] >= 1000), + ("🌱", "Quick Streak", "Commit for 3+ consecutive days", + lambda c: c["longest_streak"] >= 3), + ("📆", "Weekday Warrior", "Commit for 5+ consecutive days", + lambda c: c["longest_streak"] >= 5), + ("📅", "Week Streak", "Commit for 7+ consecutive days", + lambda c: c["longest_streak"] >= 7), + ("💫", "Fortnight Streak", "Commit for 14+ consecutive days", + lambda c: c["longest_streak"] >= 14), + ("🗓️", "Three-Week Streak", "Commit for 21+ consecutive days", + lambda c: c["longest_streak"] >= 21), + ("🔥", "Month Streak", "Commit for 30+ consecutive days", + lambda c: c["longest_streak"] >= 30), + ("⬜", "Common Ground", "Reach a common-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["common"]), + ("🟩", "Uncommon Rising", "Reach an uncommon-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["uncommon"]), + ("🟦", "Rare Find", "Reach a rare-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["rare"]), + ("🟪", "Epic Coder", "Reach an epic-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["epic"]), + ("🟧", "Legendary Dev", "Reach a legendary-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["legendary"]), + ("🟥", "Mythic Status", "Reach a mythic-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["mythic"]), + ("⬛", "Absolute Power", "Reach an absolute-rarity level", + lambda c: _peak_rarity_rank(c) >= RARITY_RANK["absolute"]), +] + + +def get_achievements(contributor: dict) -> list[tuple[str, str]]: + """Return a list of (emoji, label) tuples the contributor has earned.""" + return [ + (emoji, label) + for emoji, label, _desc, check in ACHIEVEMENTS + if check(contributor) + ] diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py new file mode 100644 index 0000000..cf47675 --- /dev/null +++ b/backend/app/services/cache.py @@ -0,0 +1,52 @@ +"""TTL-based in-memory cache for leaderboard data.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any + +from backend.app.config import settings + + +class TTLCache: + """Simple async-safe TTL cache for leaderboard data. + + Stores cached values with a time-to-live. Thread-safe through + ``asyncio.Lock``. + """ + + def __init__(self, default_ttl: int | None = None) -> None: + self._store: dict[str, tuple[Any, float]] = {} + self._lock = asyncio.Lock() + self._default_ttl = default_ttl or settings.cache_ttl + + async def get(self, key: str) -> Any | None: + """Return the cached value for *key*, or ``None`` if expired/missing.""" + async with self._lock: + entry = self._store.get(key) + if entry is None: + return None + value, expiry = entry + if time.monotonic() > expiry: + del self._store[key] + return None + return value + + async def set(self, key: str, value: Any, ttl: int | None = None) -> None: + """Store *value* under *key* with an optional custom TTL.""" + async with self._lock: + expiry = time.monotonic() + (ttl or self._default_ttl) + self._store[key] = (value, expiry) + + async def invalidate(self, key: str | None = None) -> None: + """Remove a specific key or clear the entire cache.""" + async with self._lock: + if key is None: + self._store.clear() + else: + self._store.pop(key, None) + + +# Global cache instance +cache = TTLCache() diff --git a/backend/app/services/github_client.py b/backend/app/services/github_client.py new file mode 100644 index 0000000..22f2dff --- /dev/null +++ b/backend/app/services/github_client.py @@ -0,0 +1,71 @@ +"""Async GitHub API client using httpx.""" + +from __future__ import annotations + +import httpx + +from backend.app.config import settings + +_HEADERS = { + "Accept": "application/vnd.github+json", + "User-Agent": "NextCommunity-Leaderboard-Backend", + "X-GitHub-Api-Version": "2022-11-28", +} + +_TIMEOUT = 30.0 + + +def _auth_headers() -> dict[str, str]: + headers = dict(_HEADERS) + if settings.github_token: + headers["Authorization"] = f"Bearer {settings.github_token}" + return headers + + +async def gh_request(url: str) -> dict | list: + """Make an authenticated GitHub API request and return parsed JSON.""" + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(url, headers=_auth_headers()) + resp.raise_for_status() + if not resp.text.strip(): + return {} + return resp.json() + + +async def get_all_pages(url: str) -> list[dict]: + """Paginate through all results for a GitHub API endpoint.""" + results: list[dict] = [] + page = 1 + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + while True: + separator = "&" if "?" in url else "?" + page_url = f"{url}{separator}per_page=100&page={page}" + resp = await client.get(page_url, headers=_auth_headers()) + resp.raise_for_status() + data = resp.json() + if isinstance(data, dict): + msg = data.get("message", "Unknown error") + raise httpx.HTTPStatusError( + f"Expected list from {url}, got: {msg}", + request=resp.request, + response=resp, + ) + if not isinstance(data, list) or not data: + break + results.extend(data) + if len(data) < 100: + break + page += 1 + return results + + +async def fetch_repos() -> list[dict]: + """Fetch all public repos for the organization.""" + url = f"https://api.github.com/orgs/{settings.org_name}/repos?type=public" + return await get_all_pages(url) + + +async def fetch_commits(repo_name: str) -> list[dict]: + """Fetch all commits for a single repo.""" + url = f"https://api.github.com/repos/{settings.org_name}/{repo_name}/commits" + return await get_all_pages(url) diff --git a/backend/app/services/leaderboard.py b/backend/app/services/leaderboard.py new file mode 100644 index 0000000..5b9e5c4 --- /dev/null +++ b/backend/app/services/leaderboard.py @@ -0,0 +1,263 @@ +"""Core leaderboard aggregation service. + +Async reimplementation of ``scripts/leaderboard.py``'s ``build_leaderboard`` +logic using httpx for non-blocking GitHub API access. +""" + +from __future__ import annotations + +import re +from collections import namedtuple +from datetime import date, timedelta + +from backend.app.services.achievements import get_achievements +from backend.app.services.github_client import fetch_commits, fetch_repos +from backend.app.services.levels import ( + build_levels_lookup, + compute_level, + compute_peak_rarity, + fetch_levels_json, + sorted_level_keys, +) + +SITE_REPO_NAME = "NextCommunity.github.io" +DOTGITHUB_REPO_NAME = ".github" + +# Manual email-to-login mapping. +EMAIL_ALIASES: dict[str, str] = {} + +CommitRecord = namedtuple( + "CommitRecord", + ["login", "email", "is_bot", "repo_name", "commit_date", "is_coauthor"], +) + +_CO_AUTHOR_RE = re.compile( + r"^Co-authored-by:\s*.+?\s*<([^>]+)>\s*$", + re.MULTILINE | re.IGNORECASE, +) + +# --- Points configuration --- +POINTS_CONFIG: dict = { + "per_commit": 10, + "per_streak_day": 5, + "per_achievement": 15, + "per_extra_repo": 20, + "rarity_bonus": { + "common": 0, + "uncommon": 10, + "rare": 25, + "epic": 50, + "legendary": 100, + "mythic": 200, + "absolute": 500, + }, +} + + +def parse_co_authors(message: str) -> list[str]: + """Extract co-author email addresses from ``Co-authored-by:`` trailers.""" + if not message: + return [] + return [m.lower().strip() for m in _CO_AUTHOR_RE.findall(message)] + + +def resolve_login_from_noreply(email: str) -> str | None: + """Extract a GitHub login from a noreply email address.""" + if email.endswith("@users.noreply.github.com"): + local = email.split("@")[0] + if "+" in local: + return local.split("+", 1)[1] + return local + return None + + +def compute_longest_streak(commit_dates: set[date]) -> int: + """Return the longest consecutive-day commit streak from a set of dates.""" + if not commit_dates: + return 0 + sorted_dates = sorted(set(commit_dates)) + longest = 1 + current = 1 + for prev, cur in zip(sorted_dates, sorted_dates[1:]): + if cur - prev == timedelta(days=1): + current += 1 + longest = max(longest, current) + elif cur - prev > timedelta(days=1): + current = 1 + return longest + + +def compute_points(contributor: dict) -> int: + """Return a gamified point total for a contributor.""" + cfg = POINTS_CONFIG + pts = contributor["commits"] * cfg["per_commit"] + pts += contributor["longest_streak"] * cfg["per_streak_day"] + pts += len(contributor["achievements"]) * cfg["per_achievement"] + extra_repos = max(contributor["repos_count"] - 1, 0) + pts += extra_repos * cfg["per_extra_repo"] + pts += cfg["rarity_bonus"].get( + contributor.get("peak_rarity", "common"), 0, + ) + return pts + + +async def build_leaderboard() -> tuple[list[dict], bool, list[dict]]: + """Aggregate contributor commits across all repos and return sorted list. + + Returns ``(sorted_contributors, had_errors, levels_data)``. + """ + repos = await fetch_repos() + had_errors = False + + all_commits: list[CommitRecord] = [] + bot_logins: set[str] = set() + bot_emails: set[str] = set() + + for repo in repos: + if repo.get("fork"): + continue + repo_name = repo["name"] + try: + for commit_obj in await fetch_commits(repo_name): + gh_author = commit_obj.get("author") + commit_detail = commit_obj.get("commit", {}) + git_author = commit_detail.get("author", {}) + email = (git_author.get("email") or "").lower().strip() + + login = None + if gh_author and gh_author.get("login"): + login = gh_author["login"] + + is_bot = bool( + (gh_author and gh_author.get("type") == "Bot") + or (login and login.endswith("[bot]")) + or ( + gh_author + and "/apps/" in (gh_author.get("html_url") or "") + ) + ) + + if is_bot: + if login: + bot_logins.add(login.lower()) + if email: + bot_emails.add(email) + + commit_date_str = git_author.get("date", "") + commit_date = None + if commit_date_str: + try: + commit_date = date.fromisoformat( + commit_date_str[:10] + ) + except ValueError: + pass + + all_commits.append(CommitRecord( + login=login, + email=email, + is_bot=is_bot, + repo_name=repo_name, + commit_date=commit_date, + is_coauthor=False, + )) + + message = commit_detail.get("message", "") + for co_email in parse_co_authors(message): + if co_email != email and co_email not in bot_emails: + all_commits.append(CommitRecord( + login=None, + email=co_email, + is_bot=False, + repo_name=repo_name, + commit_date=commit_date, + is_coauthor=True, + )) + except Exception: + had_errors = True + + # Phase 1: email → login mapping + email_to_login: dict[str, str] = dict(EMAIL_ALIASES) + for rec in all_commits: + if not rec.email: + continue + if rec.login and rec.email not in email_to_login: + email_to_login[rec.email] = rec.login + elif not rec.login and rec.email not in email_to_login: + resolved = resolve_login_from_noreply(rec.email) + if resolved: + email_to_login[rec.email] = resolved + + # Phase 2: count commits per resolved identity + contributors: dict[str, dict] = {} + for rec in all_commits: + if rec.is_bot: + continue + resolved = rec.login or email_to_login.get(rec.email) + if not resolved: + continue + if ( + resolved.endswith("[bot]") + or resolved.lower() in bot_logins + or rec.email in bot_emails + ): + continue + + if resolved not in contributors: + contributors[resolved] = { + "commits": 0, + "authored_commits": 0, + "coauthored_commits": 0, + "site_commits": 0, + "dotgithub_commits": 0, + "login": resolved, + "repos": set(), + "commit_dates": set(), + } + contributors[resolved]["commits"] += 1 + if rec.is_coauthor: + contributors[resolved]["coauthored_commits"] += 1 + else: + contributors[resolved]["authored_commits"] += 1 + contributors[resolved]["repos"].add(rec.repo_name) + if rec.commit_date is not None: + contributors[resolved]["commit_dates"].add(rec.commit_date) + if rec.repo_name == SITE_REPO_NAME: + contributors[resolved]["site_commits"] += 1 + elif rec.repo_name == DOTGITHUB_REPO_NAME: + contributors[resolved]["dotgithub_commits"] += 1 + + # Fetch canonical level definitions + levels_data = await fetch_levels_json() + levels_lookup = build_levels_lookup(levels_data) + sk = sorted_level_keys(levels_lookup) + + # Compute gamification stats for each contributor + for contrib in contributors.values(): + contrib["repos_count"] = len(contrib["repos"]) + contrib["repo_names"] = sorted(contrib["repos"]) + contrib["longest_streak"] = compute_longest_streak( + contrib["commit_dates"] + ) + level_info = compute_level( + contrib["commits"], levels_lookup, _sorted_keys=sk, + ) + contrib["level_num"] = level_info.get("level", 0) + contrib["level_emoji"] = level_info.get("emoji", "🐣") + contrib["level_title"] = level_info.get("name", "Newbie") + contrib["level_rarity"] = level_info.get("rarity", "common") + contrib["level_description"] = level_info.get("description", "") + contrib["level_color"] = level_info.get("color", "#94a3b8") + contrib["peak_rarity"] = compute_peak_rarity( + contrib["commits"], levels_lookup, _sorted_keys=sk, + ) + contrib["achievements"] = get_achievements(contrib) + contrib["points"] = compute_points(contrib) + # Remove non-serializable fields + del contrib["repos"] + del contrib["commit_dates"] + + sorted_contributors = sorted( + contributors.values(), key=lambda c: c["commits"], reverse=True + ) + return sorted_contributors, had_errors, levels_data diff --git a/backend/app/services/levels.py b/backend/app/services/levels.py new file mode 100644 index 0000000..1c00f03 --- /dev/null +++ b/backend/app/services/levels.py @@ -0,0 +1,182 @@ +"""Level computation service. + +Mirrors the level system from ``scripts/leaderboard.py`` using the same +canonical level definitions and bisect-based lookup. +""" + +from __future__ import annotations + +import json + +import httpx + +from backend.app.config import settings + +# --- Rarity visual indicators --- +RARITY_INDICATORS: dict[str, str] = { + "common": "⬜", + "uncommon": "🟩", + "rare": "🟦", + "epic": "🟪", + "legendary": "🟧", + "mythic": "🟥", + "absolute": "⬛", +} + +RARITY_ORDER: list[str] = list(RARITY_INDICATORS) +RARITY_RANK: dict[str, int] = {r: i for i, r in enumerate(RARITY_ORDER)} + +# Milestones used for the progress bar. +MILESTONES: list[int] = [ + 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, + 150, 200, 250, 300, 400, 500, 750, 1000, +] + +# Curated level samples shown in guides. +SAMPLE_LEVELS: list[int] = [0, 1, 5, 10, 25, 50, 100, 200, 250, 500, 750, 1000] + +# Minimal built-in fallback levels. +FALLBACK_LEVELS: list[dict] = [ + {"level": 0, "name": "Newbie", "emoji": "🐣", "color": "#94a3b8", + "rarity": "common", "description": "Hello World."}, + {"level": 1, "name": "Script Kid", "emoji": "🛹", "color": "#10b981", + "rarity": "common", "description": "Copy-paste from Stack Overflow."}, + {"level": 5, "name": "Data Miner", "emoji": "💎", "color": "#06b6d4", + "rarity": "uncommon", "description": "Sifting through JSON for gold."}, + {"level": 10, "name": "Architect", "emoji": "👑", "color": "#ef4444", + "rarity": "epic", "description": "You dream in UML diagrams."}, + {"level": 25, "name": "Kingslayer", "emoji": "🗡️", "color": "#facc15", + "rarity": "epic", "description": "There are no men like me."}, + {"level": 50, "name": "Ring-bearer", "emoji": "💍", "color": "#fbbf24", + "rarity": "legendary", "description": "Carry it to the fire."}, + {"level": 100, "name": "Eru Ilúvatar", "emoji": "✨", "color": "#fbbf24", + "rarity": "mythic", "description": "The Creator."}, + {"level": 200, "name": "One With The Force", "emoji": "🌌", + "color": "#6366f1", "rarity": "mythic", + "description": "Luminous beings are we."}, + {"level": 250, "name": "The Source", "emoji": "🔆", + "color": "#ffffff", "rarity": "mythic", + "description": "Where the path ends and the cycle restarts."}, + {"level": 500, "name": "The Creative Director", "emoji": "✨", + "color": "#ffffff", "rarity": "mythic", + "description": "The vision is complete. Roll credits."}, + {"level": 750, "name": "Meta-Reality Architect", "emoji": "🏛️", + "color": "#fbbf24", "rarity": "mythic", + "description": "You designed the cage you live in. It's quite nice."}, + {"level": 1000, "name": "Infinity", "emoji": "♾️", "color": "#000000", + "rarity": "absolute", "description": "Beyond all limits."}, +] + +_DEFAULT_LEVEL: dict = { + "level": 0, "name": "Newbie", "emoji": "🐣", + "rarity": "common", "description": "", "color": "#94a3b8", +} + + +async def fetch_levels_json() -> list[dict]: + """Fetch canonical level definitions from the website repository.""" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + settings.levels_json_url, + headers={"User-Agent": "NextCommunity-Leaderboard-Backend"}, + ) + resp.raise_for_status() + data = json.loads(resp.text) + if isinstance(data, list) and data: + data.sort(key=lambda d: d.get("level", 0)) + return data + except Exception: + pass + return list(FALLBACK_LEVELS) + + +def build_levels_lookup(levels_data: list[dict]) -> dict[int, dict]: + """Return a dict mapping level number → level dict.""" + return {entry["level"]: entry for entry in levels_data} + + +def sorted_level_keys(levels_lookup: dict[int, dict]) -> list[int]: + """Return sorted level keys for bisect-based lookups.""" + return sorted(levels_lookup) + + +def compute_level( + commits: int, + levels_lookup: dict[int, dict], + _sorted_keys: list[int] | None = None, +) -> dict: + """Return a level-info dict for a commit count.""" + from bisect import bisect_right + + if not levels_lookup: + return dict(_DEFAULT_LEVEL) + + if _sorted_keys is None: + _sorted_keys = sorted_level_keys(levels_lookup) + + idx = bisect_right(_sorted_keys, commits) - 1 + if idx < 0: + idx = 0 + level_num = _sorted_keys[idx] + return dict(levels_lookup.get(level_num, _DEFAULT_LEVEL)) + + +def compute_peak_rarity( + commits: int, + levels_lookup: dict[int, dict], + _sorted_keys: list[int] | None = None, +) -> str: + """Return the highest rarity achieved for defined levels up to *commits*.""" + if not levels_lookup: + return _DEFAULT_LEVEL.get("rarity", "common") + + if _sorted_keys is None: + _sorted_keys = sorted_level_keys(levels_lookup) + + best_rarity = "common" + best_rank = RARITY_RANK["common"] + for key in _sorted_keys: + if key > commits: + break + entry_rarity = levels_lookup[key].get("rarity", "common") + entry_rank = RARITY_RANK.get(entry_rarity, 0) + if entry_rank > best_rank: + best_rarity = entry_rarity + best_rank = entry_rank + return best_rarity + + +def next_milestone(commits: int) -> int | None: + """Return the next milestone target above *commits*, or ``None`` at max.""" + for m in MILESTONES: + if commits < m: + return m + return None + + +def prev_milestone(commits: int) -> int: + """Return the last milestone at or below *commits*, or 0.""" + prev = 0 + for m in MILESTONES: + if m <= commits: + prev = m + else: + break + return prev + + +def progress_bar(commits: int, width: int = 8) -> str: + """Return a text progress bar toward the next milestone.""" + target = next_milestone(commits) + if target is None: + return "MAX ✨" + base = prev_milestone(commits) + span = target - base + if span <= 0: + return "MAX ✨" + progress = commits - base + filled = min((width * progress) // span, width) + empty = width - filled + pct = min((100 * progress) // span, 100) + return f"[{'█' * filled}{'░' * empty}] {pct}% → {target}" diff --git a/backend/deploy/cloudbuild.yaml b/backend/deploy/cloudbuild.yaml new file mode 100644 index 0000000..e18a5ae --- /dev/null +++ b/backend/deploy/cloudbuild.yaml @@ -0,0 +1,32 @@ +steps: + - name: "Build container image" + id: "build" + entrypoint: "docker" + args: [ + "build", + "-t", "gcr.io/$PROJECT_ID/nextcommunity-leaderboard", + "-f", "backend/Dockerfile", + "." + ] + + - name: "gcr.io/cloud-builders/docker" + args: ["push", "gcr.io/$PROJECT_ID/nextcommunity-leaderboard"] + + - name: "gcr.io/google.com/cloudsdktool/cloud-sdk" + entrypoint: gcloud + args: + - "run" + - "deploy" + - "nextcommunity-leaderboard" + - "--image" + - "gcr.io/$PROJECT_ID/nextcommunity-leaderboard" + - "--region" + - "us-central1" + - "--platform" + - "managed" + - "--allow-unauthenticated" + - "--port" + - "8000" + +images: + - "gcr.io/$PROJECT_ID/nextcommunity-leaderboard" diff --git a/backend/deploy/fly.toml b/backend/deploy/fly.toml new file mode 100644 index 0000000..e640443 --- /dev/null +++ b/backend/deploy/fly.toml @@ -0,0 +1,24 @@ +app = "nextcommunity-leaderboard" +primary_region = "iad" + +[build] + dockerfile = "backend/Dockerfile" + +[http_service] + internal_port = 8000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + + [http_service.concurrency] + type = "requests" + hard_limit = 250 + soft_limit = 200 + +[[http_service.checks]] + interval = 30000 + timeout = 5000 + grace_period = "10s" + method = "GET" + path = "/health" diff --git a/backend/deploy/lambda_handler.py b/backend/deploy/lambda_handler.py new file mode 100644 index 0000000..5e0b50a --- /dev/null +++ b/backend/deploy/lambda_handler.py @@ -0,0 +1,11 @@ +"""AWS Lambda handler using Mangum adapter. + +To use this handler, install mangum: ``pip install mangum`` +Set the Lambda handler to ``backend.deploy.lambda_handler.handler``. +""" + +from mangum import Mangum + +from backend.app.main import app + +handler = Mangum(app, lifespan="off") diff --git a/backend/deploy/leaderboard-api.service b/backend/deploy/leaderboard-api.service new file mode 100644 index 0000000..f192f2a --- /dev/null +++ b/backend/deploy/leaderboard-api.service @@ -0,0 +1,18 @@ +[Unit] +Description=NextCommunity Leaderboard API +After=network.target + +[Service] +Type=simple +User=appuser +Group=appuser +WorkingDirectory=/opt/nextcommunity-leaderboard +EnvironmentFile=/opt/nextcommunity-leaderboard/.env +ExecStart=/opt/nextcommunity-leaderboard/venv/bin/uvicorn backend.app.main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/backend/deploy/nginx.conf b/backend/deploy/nginx.conf new file mode 100644 index 0000000..02f2926 --- /dev/null +++ b/backend/deploy/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name your-domain.com; + + # Redirect HTTP to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL certificates (use certbot / Let's Encrypt) + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://127.0.0.1:8000/health; + access_log off; + } +} diff --git a/backend/deploy/railway.toml b/backend/deploy/railway.toml new file mode 100644 index 0000000..d0290f8 --- /dev/null +++ b/backend/deploy/railway.toml @@ -0,0 +1,8 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "uvicorn backend.app.main:app --host 0.0.0.0 --port $PORT" +healthcheckPath = "/health" +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/backend/deploy/render.yaml b/backend/deploy/render.yaml new file mode 100644 index 0000000..f0d9d22 --- /dev/null +++ b/backend/deploy/render.yaml @@ -0,0 +1,18 @@ +services: + - type: web + name: nextcommunity-leaderboard-api + runtime: python + buildCommand: pip install -r backend/requirements.txt + startCommand: uvicorn backend.app.main:app --host 0.0.0.0 --port $PORT + healthCheckPath: /health + envVars: + - key: GITHUB_TOKEN + sync: false + - key: ORG_NAME + value: NextCommunity + - key: CACHE_TTL + value: "900" + - key: API_KEY + sync: false + - key: PYTHON_VERSION + value: "3.13" diff --git a/backend/deploy/template.yaml b/backend/deploy/template.yaml new file mode 100644 index 0000000..3aca1a1 --- /dev/null +++ b/backend/deploy/template.yaml @@ -0,0 +1,43 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: NextCommunity Leaderboard API on AWS Lambda + +Globals: + Function: + Timeout: 30 + MemorySize: 256 + Runtime: python3.13 + +Resources: + LeaderboardFunction: + Type: AWS::Serverless::Function + Properties: + Handler: backend.deploy.lambda_handler.handler + CodeUri: ../ + Environment: + Variables: + GITHUB_TOKEN: !Ref GitHubToken + ORG_NAME: NextCommunity + CACHE_TTL: "900" + Events: + ApiProxy: + Type: HttpApi + Properties: + Path: /{proxy+} + Method: ANY + ApiRoot: + Type: HttpApi + Properties: + Path: / + Method: ANY + +Parameters: + GitHubToken: + Type: String + NoEcho: true + Description: GitHub personal access token + +Outputs: + ApiUrl: + Description: API Gateway URL + Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..71f8ada --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,20 @@ +services: + api: + build: + context: .. + dockerfile: backend/Dockerfile + ports: + - "${PORT:-8000}:8000" + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + - ORG_NAME=${ORG_NAME:-NextCommunity} + - CACHE_TTL=${CACHE_TTL:-900} + - API_KEY=${API_KEY:-} + - LOG_LEVEL=${LOG_LEVEL:-info} + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6a56901 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,15 @@ +# Backend dependencies +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +httpx==0.28.1 +pydantic==2.11.1 +pydantic-settings==2.9.1 +python-dotenv==1.1.0 + +# Development / testing +pytest==8.3.5 +pytest-asyncio==0.25.3 +httpx # also used by TestClient + +# Optional: AWS Lambda adapter +# mangum==0.19.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..584cb1a --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,169 @@ +"""Integration tests for API endpoints using FastAPI TestClient.""" + +import pytest +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from backend.app.main import app +from backend.app.services.levels import FALLBACK_LEVELS + +client = TestClient(app) + +# Sample contributor data for mocking +MOCK_CONTRIBUTORS = [ + { + "login": "alice", + "commits": 150, + "authored_commits": 140, + "coauthored_commits": 10, + "site_commits": 50, + "dotgithub_commits": 20, + "repos_count": 3, + "repo_names": [".github", "NextCommunity.github.io", "project-a"], + "longest_streak": 14, + "level_num": 100, + "level_emoji": "✨", + "level_title": "Eru Ilúvatar", + "level_rarity": "mythic", + "level_description": "The Creator.", + "level_color": "#fbbf24", + "peak_rarity": "mythic", + "achievements": [("🎯", "First Commit"), ("💪", "Dedicated"), ("🚀", "Rockstar")], + "points": 1800, + }, + { + "login": "bob", + "commits": 25, + "authored_commits": 25, + "coauthored_commits": 0, + "site_commits": 10, + "dotgithub_commits": 5, + "repos_count": 2, + "repo_names": [".github", "NextCommunity.github.io"], + "longest_streak": 3, + "level_num": 25, + "level_emoji": "🗡️", + "level_title": "Kingslayer", + "level_rarity": "epic", + "level_description": "There are no men like me.", + "level_color": "#facc15", + "peak_rarity": "epic", + "achievements": [("🎯", "First Commit"), ("🌟", "Rising Star")], + "points": 400, + }, +] + +MOCK_RESULT = (MOCK_CONTRIBUTORS, False, FALLBACK_LEVELS) + + +@pytest.fixture(autouse=True) +def mock_cache(): + """Clear cache and mock build_leaderboard for all tests.""" + with patch( + "backend.app.routers.leaderboard.build_leaderboard", + new_callable=AsyncMock, + return_value=MOCK_RESULT, + ): + with patch( + "backend.app.services.cache.cache.get", + new_callable=AsyncMock, + return_value=None, + ): + with patch( + "backend.app.services.cache.cache.set", + new_callable=AsyncMock, + ): + yield + + +def test_health(): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "healthy" + assert "version" in data + + +def test_get_leaderboard(): + resp = client.get("/api/leaderboard") + assert resp.status_code == 200 + data = resp.json() + assert data["total_contributors"] == 2 + assert data["had_errors"] is False + assert len(data["contributors"]) == 2 + assert data["contributors"][0]["login"] == "alice" + assert data["contributors"][0]["rank"] == 1 + assert data["contributors"][1]["login"] == "bob" + assert data["contributors"][1]["rank"] == 2 + + +def test_get_contributor_found(): + resp = client.get("/api/contributors/alice") + assert resp.status_code == 200 + data = resp.json() + assert data["login"] == "alice" + assert data["commits"] == 150 + assert data["level_title"] == "Eru Ilúvatar" + assert len(data["achievements"]) == 3 + assert data["points"] == 1800 + + +def test_get_contributor_case_insensitive(): + resp = client.get("/api/contributors/Alice") + assert resp.status_code == 200 + assert resp.json()["login"] == "alice" + + +def test_get_contributor_not_found(): + resp = client.get("/api/contributors/unknown") + assert resp.status_code == 404 + + +def test_get_stats(): + resp = client.get("/api/stats") + assert resp.status_code == 200 + data = resp.json() + assert data["total_contributors"] == 2 + assert data["total_commits"] == 175 + assert data["total_authored"] == 165 + assert data["total_coauthored"] == 10 + assert "rarity_distribution" in data + + +def test_get_levels(): + resp = client.get("/api/levels") + assert resp.status_code == 200 + data = resp.json() + assert data["total_levels"] == len(FALLBACK_LEVELS) + assert len(data["levels"]) == len(FALLBACK_LEVELS) + assert data["levels"][0]["name"] == "Newbie" + + +def test_get_achievements(): + resp = client.get("/api/achievements") + assert resp.status_code == 200 + data = resp.json() + assert data["total_achievements"] == 24 + labels = [a["label"] for a in data["achievements"]] + assert "First Commit" in labels + assert "Thousand Club" in labels + + +def test_refresh_no_api_key(): + """When no API_KEY is configured, refresh should be open.""" + resp = client.post("/api/refresh") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["contributors_count"] == 2 + + +def test_openapi_docs(): + resp = client.get("/docs") + assert resp.status_code == 200 + + +def test_timing_header(): + resp = client.get("/health") + assert "x-process-time" in resp.headers diff --git a/backend/tests/test_leaderboard.py b/backend/tests/test_leaderboard.py new file mode 100644 index 0000000..20c86c9 --- /dev/null +++ b/backend/tests/test_leaderboard.py @@ -0,0 +1,264 @@ +"""Unit tests for core leaderboard services.""" + +from datetime import date, timedelta + +from backend.app.services.achievements import ACHIEVEMENTS, get_achievements +from backend.app.services.levels import ( + FALLBACK_LEVELS, + MILESTONES, + RARITY_INDICATORS, + RARITY_ORDER, + RARITY_RANK, + build_levels_lookup, + compute_level, + compute_peak_rarity, + next_milestone, + prev_milestone, + progress_bar, + sorted_level_keys, +) +from backend.app.services.leaderboard import ( + POINTS_CONFIG, + compute_longest_streak, + compute_points, + parse_co_authors, + resolve_login_from_noreply, +) + + +# --- Level tests --- + + +def test_build_levels_lookup(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + assert 0 in lookup + assert 1000 in lookup + assert lookup[0]["name"] == "Newbie" + + +def test_sorted_level_keys(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + keys = sorted_level_keys(lookup) + assert keys == sorted(keys) + assert keys[0] == 0 + assert keys[-1] == 1000 + + +def test_compute_level_zero(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + level = compute_level(0, lookup) + assert level["level"] == 0 + assert level["name"] == "Newbie" + + +def test_compute_level_mid(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + level = compute_level(50, lookup) + assert level["level"] == 50 + assert level["name"] == "Ring-bearer" + + +def test_compute_level_between(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + level = compute_level(7, lookup) + assert level["level"] == 5 # Should round down to 5 + + +def test_compute_level_max(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + level = compute_level(9999, lookup) + assert level["level"] == 1000 + + +def test_compute_level_empty_lookup(): + level = compute_level(50, {}) + assert level["name"] == "Newbie" + + +def test_compute_peak_rarity_common(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + rarity = compute_peak_rarity(0, lookup) + assert rarity == "common" + + +def test_compute_peak_rarity_epic(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + rarity = compute_peak_rarity(25, lookup) + assert rarity == "epic" + + +def test_compute_peak_rarity_absolute(): + lookup = build_levels_lookup(FALLBACK_LEVELS) + rarity = compute_peak_rarity(1000, lookup) + assert rarity == "absolute" + + +# --- Milestone tests --- + + +def test_next_milestone(): + assert next_milestone(0) == 10 + assert next_milestone(9) == 10 + assert next_milestone(10) == 20 + assert next_milestone(999) == 1000 + assert next_milestone(1000) is None + + +def test_prev_milestone(): + assert prev_milestone(0) == 0 + assert prev_milestone(10) == 10 + assert prev_milestone(15) == 10 + assert prev_milestone(1000) == 1000 + + +def test_progress_bar_zero(): + bar = progress_bar(0) + assert "0%" in bar + assert "→ 10" in bar + + +def test_progress_bar_max(): + bar = progress_bar(1000) + assert "MAX" in bar + + +# --- Streak tests --- + + +def test_longest_streak_empty(): + assert compute_longest_streak(set()) == 0 + + +def test_longest_streak_single(): + assert compute_longest_streak({date.today()}) == 1 + + +def test_longest_streak_consecutive(): + today = date.today() + dates = {today - timedelta(days=i) for i in range(7)} + assert compute_longest_streak(dates) == 7 + + +def test_longest_streak_gap(): + today = date.today() + dates = {today, today - timedelta(days=1), today - timedelta(days=5)} + assert compute_longest_streak(dates) == 2 + + +# --- Co-author tests --- + + +def test_parse_co_authors_basic(): + msg = "Some commit\n\nCo-authored-by: Alice " + result = parse_co_authors(msg) + assert result == ["alice@example.com"] + + +def test_parse_co_authors_multiple(): + msg = ( + "commit\n\n" + "Co-authored-by: Alice \n" + "Co-authored-by: Bob " + ) + result = parse_co_authors(msg) + assert len(result) == 2 + + +def test_parse_co_authors_empty(): + assert parse_co_authors("") == [] + assert parse_co_authors(None) == [] + + +# --- Noreply resolution tests --- + + +def test_resolve_noreply_simple(): + assert resolve_login_from_noreply( + "alice@users.noreply.github.com" + ) == "alice" + + +def test_resolve_noreply_with_id(): + assert resolve_login_from_noreply( + "12345+alice@users.noreply.github.com" + ) == "alice" + + +def test_resolve_noreply_non_noreply(): + assert resolve_login_from_noreply("alice@example.com") is None + + +# --- Achievement tests --- + + +def test_achievements_first_commit(): + contrib = { + "commits": 1, + "repos_count": 1, + "longest_streak": 0, + "level_rarity": "common", + "peak_rarity": "common", + } + badges = get_achievements(contrib) + assert any(label == "First Commit" for _, label in badges) + + +def test_achievements_none(): + contrib = { + "commits": 0, + "repos_count": 0, + "longest_streak": 0, + "level_rarity": "common", + "peak_rarity": "common", + } + badges = get_achievements(contrib) + # Only "Common Ground" should trigger (peak_rarity >= common is always true) + assert len(badges) == 1 + assert badges[0][1] == "Common Ground" + + +# --- Points tests --- + + +def test_compute_points_basic(): + contrib = { + "commits": 10, + "longest_streak": 5, + "achievements": [("🎯", "First Commit")], + "repos_count": 2, + "peak_rarity": "common", + } + pts = compute_points(contrib) + expected = ( + 10 * POINTS_CONFIG["per_commit"] + + 5 * POINTS_CONFIG["per_streak_day"] + + 1 * POINTS_CONFIG["per_achievement"] + + 1 * POINTS_CONFIG["per_extra_repo"] + + POINTS_CONFIG["rarity_bonus"]["common"] + ) + assert pts == expected + + +# --- Data integrity tests --- + + +def test_rarity_order_matches_indicators(): + assert list(RARITY_INDICATORS.keys()) == RARITY_ORDER + + +def test_rarity_rank_values(): + for i, rarity in enumerate(RARITY_ORDER): + assert RARITY_RANK[rarity] == i + + +def test_achievements_count(): + assert len(ACHIEVEMENTS) == 24 + + +def test_milestones_sorted(): + assert MILESTONES == sorted(MILESTONES) + + +def test_fallback_levels_sorted(): + levels = [entry["level"] for entry in FALLBACK_LEVELS] + assert levels == sorted(levels)