diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec244c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +REPO_EVALUATOR_HOST=0.0.0.0 +REPO_EVALUATOR_PORT=8000 +REPO_EVALUATOR_LOG_LEVEL=info diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b6d691 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [main, feature/*] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests with coverage + run: pytest + + - name: Run linting + run: ruff check . + + docker-build: + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: false + tags: repo-evaluator:latest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cd6d98 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM python:3.12-slim AS builder + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +COPY README.md ./ +COPY src/ ./src/ + +RUN pip install --no-cache-dir -e "." + +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends git docker.io && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin +COPY src/ ./src/ +COPY pyproject.toml ./ +COPY README.md ./ + +RUN pip install --no-cache-dir -e "." + +EXPOSE 8000 + +CMD ["uvicorn", "repo_evaluator.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 0e1cbfd..022921c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,19 @@ Evaluate Python repositories for health, structure, and Docker readiness. +## Features + +- **Repository Analysis**: Clone and inspect any public GitHub repository. +- **Health Scoring**: Automated scoring based on README, Dockerfile, docker-compose, dependency files, tests, Python files, collected tests, and Docker build success. +- **Docker Validation**: Detects `Dockerfile` presence and attempts a build to verify it works. +- **CLI Tool**: Run evaluations from the terminal with JSON or Markdown output. +- **REST API**: FastAPI-based HTTP API for programmatic access. +- **CI/CD Ready**: GitHub Actions workflow included for automated testing and Docker validation. + ## Install +Requires Python 3.12+. + ```bash pip install -e ".[dev]" ``` @@ -13,27 +24,116 @@ pip install -e ".[dev]" ### CLI ```bash +# Evaluate a repo and output JSON (default) repo-eval evaluate https://github.com/example/repo --output json + +# Evaluate a repo and output Markdown +repo-eval evaluate https://github.com/example/repo --output markdown ``` ### API ```bash -uvicorn repo_evaluator.main:app --reload +# Start the server +uvicorn repo_evaluator.main:app --reload --host 0.0.0.0 --port 8000 ``` -Then POST to `/evaluate` with `{"repo_url": "https://github.com/example/repo"}`. +Then POST to `http://localhost:8000/api/v1/evaluate` with: + +```json +{ + "repo_url": "https://github.com/example/repo" +} +``` + +Health check: `GET http://localhost:8000/health` (also available at `/api/v1/health`) + +### Docker + +```bash +# Build and run with Docker Compose +docker-compose up --build + +# Or build manually +docker build -t repo-evaluator . +docker run -p 8000:8000 repo-evaluator +``` ## Scoring Rubric -| Category | Weight | -|----------|--------| -| README | 15 | -| Dockerfile | 15 | -| Tests | 20 | -| Python files | 10 | -| Docker build | 20 | -| Structure | 20 | +| Category | Weight | Criteria | +|----------|--------|----------| +| README | 15 | `README.md` (or `.rst`, `.txt`) present | +| Dockerfile | 15 | `Dockerfile` present | +| docker-compose | 10 | `docker-compose.yml` (or `.yaml`) present | +| Dependency file | 10 | `requirements.txt`, `pyproject.toml`, `setup.py`, or `setup.cfg` present | +| Tests directory | 10 | `tests/` or `test/` directory present | +| Python files | 10 | At least one `.py` file in the repo | +| Collected tests | 15 | `pytest --collect-only` finds > 0 tests | +| Docker build | 15 | `docker build` succeeds for the repo | + +**Max score: 100** + +## Development + +```bash +# Run tests with coverage +pytest + +# Run linting +ruff check . + +# Run type checking +mypy src/repo_evaluator +``` + +## Environment Variables + +Copy `.env.example` to `.env` and adjust as needed: + +```bash +cp .env.example .env +``` + +| Variable | Description | Default | +|----------|-------------|---------| +| `REPO_EVALUATOR_HOST` | API bind host | `0.0.0.0` | +| `REPO_EVALUATOR_PORT` | API bind port | `8000` | +| `REPO_EVALUATOR_LOG_LEVEL` | Logging level | `info` | + +## CI/CD + +This project includes a GitHub Actions workflow (`.github/workflows/ci.yml`) that: + +1. Runs the test suite with pytest and enforces 70% coverage. +2. Validates the Docker image builds successfully. +3. Runs linting with ruff. + +## Project Structure + +``` +python-repo-evaluator/ +├── src/repo_evaluator/ # Source code +│ ├── __init__.py +│ ├── analyzer.py # Repository analysis +│ ├── api.py # FastAPI routes +│ ├── cli.py # Typer CLI +│ ├── docker_validator.py # Docker build validation +│ ├── formatters.py # JSON / Markdown output +│ ├── main.py # FastAPI app factory +│ ├── models.py # Pydantic models +│ └── scorer.py # Health scoring logic +├── tests/ # Test suite (pytest) +├── scripts/ +│ └── run-local.sh # Local API startup helper +├── .github/workflows/ +│ └── ci.yml # GitHub Actions CI +├── Dockerfile # Multi-stage Python 3.12 build +├── docker-compose.yml # Local orchestration +├── .env.example # Environment template +├── pyproject.toml # Project config & dependencies +└── README.md # This file +``` ## License diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3253123 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + repo-evaluator: + build: . + ports: + - "8000:8000" + env_file: + - .env + volumes: + - /var/run/docker.sock:/var/run/docker.sock + restart: unless-stopped diff --git a/scripts/run-local.sh b/scripts/run-local.sh new file mode 100755 index 0000000..16a8dd9 --- /dev/null +++ b/scripts/run-local.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Local development startup script for python-repo-evaluator API + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +VENV_PATH="${PROJECT_ROOT}/.venv" + +if [ ! -d "$VENV_PATH" ]; then + echo "Virtual environment not found at $VENV_PATH" + echo "Run: python -m venv .venv && source .venv/bin/activate && pip install -e \".[dev]\"" + exit 1 +fi + +source "$VENV_PATH/bin/activate" + +HOST="${REPO_EVALUATOR_HOST:-0.0.0.0}" +PORT="${REPO_EVALUATOR_PORT:-8000}" +LOG_LEVEL="${REPO_EVALUATOR_LOG_LEVEL:-info}" + +echo "Starting python-repo-evaluator API on http://${HOST}:${PORT}" +echo "Health check: http://${HOST}:${PORT}/api/v1/health" +echo "Evaluate endpoint: POST http://${HOST}:${PORT}/api/v1/evaluate" +echo "Press Ctrl+C to stop" + +exec uvicorn repo_evaluator.main:app \ + --host "$HOST" \ + --port "$PORT" \ + --log-level "$LOG_LEVEL" \ + --reload diff --git a/src/repo_evaluator/analyzer.py b/src/repo_evaluator/analyzer.py index 79d33a1..bd46f8d 100644 --- a/src/repo_evaluator/analyzer.py +++ b/src/repo_evaluator/analyzer.py @@ -1,9 +1,9 @@ from __future__ import annotations import shutil +import subprocess import tempfile from pathlib import Path -from typing import Any from git import Repo @@ -25,6 +25,7 @@ def analyze( key_files = self._detect_key_files(temp_dir) python_files = self._count_python_files(temp_dir) tests = self._detect_tests(temp_dir) + collected_tests = self._collect_tests(temp_dir) docker = DockerResult(present=key_files["Dockerfile"]) if docker_validator and docker.present: docker = docker_validator.validate(temp_dir) @@ -35,6 +36,7 @@ def analyze( key_files=key_files, python_files=python_files, tests=tests, + collected_tests=collected_tests, docker=docker, ) finally: @@ -43,7 +45,7 @@ def analyze( def _clone(self, repo_url: str) -> Path: """Clone repository into a temporary directory and return its path.""" temp_dir = Path(tempfile.mkdtemp(prefix="repo_evaluator_")) - Repo.clone_from(repo_url, temp_dir) + Repo.clone_from(repo_url, temp_dir, depth=1) return temp_dir def _detect_language(self, repo_path: Path) -> str | None: @@ -76,8 +78,27 @@ def _detect_tests(self, repo_path: Path) -> TestResult: return TestResult(framework="pytest", count=count) return TestResult() + def _collect_tests(self, repo_path: Path) -> int: + """Attempt to collect tests with pytest --collect-only.""" + try: + result = subprocess.run( + ["python", "-m", "pytest", "--collect-only", "-q"], + cwd=repo_path, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode != 0: + return 0 + # pytest -q outputs one line per collected test/module; count non-empty lines + lines = [line for line in result.stdout.splitlines() if line.strip()] + # Filter out summary lines like "no tests ran" + return len([line for line in lines if "test" in line.lower() or ".py" in line]) + except Exception: + return 0 + def _detect_key_files(self, repo_path: Path) -> dict[str, bool]: - """Check presence of README, Dockerfile, and docker-compose.""" + """Check presence of key project files and directories.""" return { "README": any( (repo_path / name).is_file() @@ -88,4 +109,12 @@ def _detect_key_files(self, repo_path: Path) -> dict[str, bool]: (repo_path / name).is_file() for name in ("docker-compose.yml", "docker-compose.yaml") ), + "dependency_file": any( + (repo_path / name).is_file() + for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg") + ), + "tests_directory": any( + (repo_path / name).is_dir() + for name in ("tests", "test") + ), } diff --git a/src/repo_evaluator/api.py b/src/repo_evaluator/api.py index c0bb317..c1b6b29 100644 --- a/src/repo_evaluator/api.py +++ b/src/repo_evaluator/api.py @@ -10,7 +10,6 @@ from repo_evaluator.models import RepoReport from repo_evaluator.scorer import HealthScorer - router = APIRouter() diff --git a/src/repo_evaluator/cli.py b/src/repo_evaluator/cli.py index 09e1b91..cb96dd9 100644 --- a/src/repo_evaluator/cli.py +++ b/src/repo_evaluator/cli.py @@ -2,14 +2,11 @@ from __future__ import annotations -import sys - import typer from repo_evaluator.analyzer import RepoAnalyzer from repo_evaluator.docker_validator import DockerValidator from repo_evaluator.formatters import to_json, to_markdown -from repo_evaluator.models import OutputFormat from repo_evaluator.scorer import HealthScorer app = typer.Typer(help="Evaluate Python repositories for health, structure, and Docker readiness.") @@ -45,7 +42,7 @@ def evaluate( typer.echo(to_markdown(report)) except Exception as exc: typer.echo(f"Error: {exc}", err=True) - raise typer.Exit(code=1) + raise typer.Exit(code=1) from exc if __name__ == "__main__": diff --git a/src/repo_evaluator/formatters.py b/src/repo_evaluator/formatters.py index cc447b6..4357123 100644 --- a/src/repo_evaluator/formatters.py +++ b/src/repo_evaluator/formatters.py @@ -2,8 +2,6 @@ from __future__ import annotations -import json - from repo_evaluator.models import RepoReport @@ -48,6 +46,7 @@ def to_markdown(report: RepoReport) -> str: if report.tests.framework: lines.append(f"Framework: {report.tests.framework}") lines.append(f"Count: {report.tests.count}") + lines.append(f"Collected: {report.collected_tests}") else: lines.append("No tests detected.") lines.extend([ diff --git a/src/repo_evaluator/main.py b/src/repo_evaluator/main.py index 1db3d13..8a5d14f 100644 --- a/src/repo_evaluator/main.py +++ b/src/repo_evaluator/main.py @@ -2,7 +2,7 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -28,6 +28,11 @@ def create_app() -> FastAPI: async def global_exception_handler(request: Request, exc: Exception) -> JSONResponse: return JSONResponse(status_code=500, content={"detail": str(exc)}) + @application.get("/health") + async def health() -> dict[str, str]: + """Root health check endpoint.""" + return {"status": "ok"} + application.include_router(router, prefix="/api/v1") return application diff --git a/src/repo_evaluator/models.py b/src/repo_evaluator/models.py index 7b4d28b..5663cf2 100644 --- a/src/repo_evaluator/models.py +++ b/src/repo_evaluator/models.py @@ -1,12 +1,11 @@ from __future__ import annotations -from enum import Enum -from pathlib import Path +from enum import StrEnum from pydantic import BaseModel, Field, HttpUrl -class OutputFormat(str, Enum): +class OutputFormat(StrEnum): """Supported output formats for evaluation reports.""" json = "json" @@ -46,5 +45,6 @@ class RepoReport(BaseModel): key_files: dict[str, bool] = {} python_files: int = 0 tests: TestResult = TestResult() + collected_tests: int = 0 docker: DockerResult = DockerResult(present=False) health_score: HealthScore = HealthScore(value=0, breakdown={}) diff --git a/src/repo_evaluator/scorer.py b/src/repo_evaluator/scorer.py index f7a2504..e0d90c2 100644 --- a/src/repo_evaluator/scorer.py +++ b/src/repo_evaluator/scorer.py @@ -4,54 +4,70 @@ class HealthScorer: - """Scores a repository based on a health rubric.""" + """Scores a repository based on the documented 8-component health rubric.""" - # Rubric weights + # Rubric weights — must sum to 100 WEIGHTS = { "readme": 15, - "docker": 25, - "tests": 25, - "structure": 20, - "python_files": 15, + "dockerfile": 15, + "docker_compose": 10, + "dependency_file": 10, + "tests_directory": 10, + "python_files": 10, + "collected_tests": 15, + "docker_build": 15, } def score(self, report: RepoReport) -> HealthScore: """Calculate health score from a RepoReport.""" breakdown: dict[str, int] = {} - # README: 15 if present - breakdown["readme"] = self.WEIGHTS["readme"] if report.key_files.get("README") else 0 + # README present: 15 + breakdown["readme"] = ( + self.WEIGHTS["readme"] if report.key_files.get("README") else 0 + ) - # Docker: 25 if buildable, 10 if present but not buildable, 0 if absent - if report.docker.buildable: - breakdown["docker"] = self.WEIGHTS["docker"] - elif report.docker.present: - breakdown["docker"] = 10 - else: - breakdown["docker"] = 0 + # Dockerfile present: 15 + breakdown["dockerfile"] = ( + self.WEIGHTS["dockerfile"] if report.key_files.get("Dockerfile") else 0 + ) - # Tests: 25 if framework and count > 0, else 0 - if report.tests.framework and (report.tests.count or 0) > 0: - breakdown["tests"] = self.WEIGHTS["tests"] - else: - breakdown["tests"] = 0 - - # Structure: 20 if at least 2 top-level dirs, 10 if 1, else 0 - structure_count = len(report.structure_summary) - if structure_count >= 2: - breakdown["structure"] = self.WEIGHTS["structure"] - elif structure_count == 1: - breakdown["structure"] = 10 - else: - breakdown["structure"] = 0 + # docker-compose present: 10 + breakdown["docker_compose"] = ( + self.WEIGHTS["docker_compose"] + if report.key_files.get("docker-compose") + else 0 + ) + + # Dependency file present: 10 + breakdown["dependency_file"] = ( + self.WEIGHTS["dependency_file"] + if report.key_files.get("dependency_file") + else 0 + ) - # Python files: 15 if >= 3 files, 10 if 1-2, else 0 - if report.python_files >= 3: - breakdown["python_files"] = self.WEIGHTS["python_files"] - elif report.python_files >= 1: - breakdown["python_files"] = 10 + # Tests directory present: 10 + breakdown["tests_directory"] = ( + self.WEIGHTS["tests_directory"] + if report.key_files.get("tests_directory") + else 0 + ) + + # Python files > 0: 10 + breakdown["python_files"] = ( + self.WEIGHTS["python_files"] if report.python_files > 0 else 0 + ) + + # Tests collected > 0: 15 + breakdown["collected_tests"] = ( + self.WEIGHTS["collected_tests"] if report.collected_tests > 0 else 0 + ) + + # Docker build success: 15 + if report.docker.buildable: + breakdown["docker_build"] = self.WEIGHTS["docker_build"] else: - breakdown["python_files"] = 0 + breakdown["docker_build"] = 0 total = sum(breakdown.values()) return HealthScore(value=total, breakdown=breakdown) diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index 9670ba9..48f7eb1 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -1,25 +1,31 @@ from __future__ import annotations import shutil -import tempfile from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from repo_evaluator.analyzer import RepoAnalyzer from repo_evaluator.models import RepoReport class TestRepoAnalyzer: """Tests for RepoAnalyzer using mocked git and filesystem.""" + @pytest.fixture(autouse=True) + def patch_collect_tests(self): + """Avoid running real pytest collection in analyzer tests.""" + with patch.object(RepoAnalyzer, "_collect_tests", return_value=1): + yield + @pytest.fixture def sample_repo_path(self) -> Path: return Path(__file__).parent / "fixtures" / "sample_repo" def _mock_clone_with_fixture(self, mock_repo_class: MagicMock, sample_repo_path: Path) -> None: """Side effect: copy fixture into the temp directory that RepoAnalyzer creates.""" - def clone_side_effect(url: str, to_path: str) -> MagicMock: + def clone_side_effect(url: str, to_path: str, **kwargs) -> MagicMock: shutil.copytree(sample_repo_path, to_path, dirs_exist_ok=True) return MagicMock() mock_repo_class.clone_from.side_effect = clone_side_effect @@ -119,7 +125,7 @@ def test_non_python_repo(self, mock_repo_class: MagicMock) -> None: """Language is None when no .py files exist.""" from repo_evaluator.analyzer import RepoAnalyzer - def clone_no_python(url: str, to_path: str) -> MagicMock: + def clone_no_python(url: str, to_path: str, **kwargs) -> MagicMock: Path(to_path).mkdir(parents=True, exist_ok=True) (Path(to_path) / "README.md").write_text("# JS Repo") return MagicMock() @@ -135,7 +141,7 @@ def test_no_tests_detected(self, mock_repo_class: MagicMock) -> None: """TestResult is empty when no test files exist.""" from repo_evaluator.analyzer import RepoAnalyzer - def clone_no_tests(url: str, to_path: str) -> MagicMock: + def clone_no_tests(url: str, to_path: str, **kwargs) -> MagicMock: Path(to_path).mkdir(parents=True, exist_ok=True) (Path(to_path) / "app.py").write_text("print(1)") return MagicMock() diff --git a/tests/test_api.py b/tests/test_api.py index a0c0c91..ed63868 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -9,7 +9,6 @@ from repo_evaluator.main import app - client = TestClient(app) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5d7091d..0ecf9cd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,13 +4,11 @@ from unittest.mock import MagicMock, patch -import pytest from typer.testing import CliRunner from repo_evaluator.cli import app from repo_evaluator.models import DockerResult, HealthScore, RepoReport, TestResult - runner = CliRunner() diff --git a/tests/test_docker_validator.py b/tests/test_docker_validator.py index fff2e54..a7a036e 100644 --- a/tests/test_docker_validator.py +++ b/tests/test_docker_validator.py @@ -4,8 +4,6 @@ from subprocess import CalledProcessError, TimeoutExpired from unittest.mock import MagicMock, patch -import pytest - from repo_evaluator.models import DockerResult diff --git a/tests/test_formatters.py b/tests/test_formatters.py index a9aa6a8..87c722a 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -4,8 +4,6 @@ import json -import pytest - from repo_evaluator.formatters import to_json, to_markdown from repo_evaluator.models import DockerResult, HealthScore, RepoReport, TestResult diff --git a/tests/test_models.py b/tests/test_models.py index 290dcb4..ddbc402 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from pydantic import ValidationError from repo_evaluator.models import ( DockerResult, @@ -52,11 +53,11 @@ def test_valid_score(self) -> None: assert score.breakdown == {"readme": 15, "tests": 20} def test_score_below_zero_raises(self) -> None: - with pytest.raises(Exception): + with pytest.raises(ValidationError): HealthScore(value=-1, breakdown={}) def test_score_above_100_raises(self) -> None: - with pytest.raises(Exception): + with pytest.raises(ValidationError): HealthScore(value=101, breakdown={}) @@ -70,7 +71,7 @@ def test_minimal_report(self) -> None: assert report.python_files == 0 def test_invalid_url_raises(self) -> None: - with pytest.raises(Exception): + with pytest.raises(ValidationError): RepoReport(repo_url="not-a-url") def test_full_report(self) -> None: diff --git a/tests/test_scorer.py b/tests/test_scorer.py index 07fbf3b..edb4889 100644 --- a/tests/test_scorer.py +++ b/tests/test_scorer.py @@ -1,28 +1,39 @@ from __future__ import annotations -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - from repo_evaluator.models import DockerResult, HealthScore, RepoReport, TestResult class TestHealthScorer: - """Tests for HealthScorer rubric math.""" + """Tests for HealthScorer rubric math using the 8-component spec rubric.""" + + def _base_key_files(self, **overrides) -> dict[str, bool]: + return { + "README": False, + "Dockerfile": False, + "docker-compose": False, + "dependency_file": False, + "tests_directory": False, + **overrides, + } - # RED: test_perfect_repo def test_perfect_repo(self) -> None: - """A repo with everything gets 100.""" + """A repo meeting all eight conditions gets 100.""" from repo_evaluator.scorer import HealthScorer report = RepoReport( repo_url="https://github.com/example/perfect", language="Python", structure_summary=["src", "tests", "docs"], - key_files={"README": True, "Dockerfile": True, "docker-compose": True}, + key_files=self._base_key_files( + README=True, + Dockerfile=True, + **{"docker-compose": True}, + dependency_file=True, + tests_directory=True, + ), python_files=10, tests=TestResult(framework="pytest", count=5), + collected_tests=5, docker=DockerResult(present=True, buildable=True), ) scorer = HealthScorer() @@ -30,33 +41,38 @@ def test_perfect_repo(self) -> None: assert isinstance(score, HealthScore) assert score.value == 100 assert score.breakdown["readme"] == 15 - assert score.breakdown["docker"] == 25 - assert score.breakdown["tests"] == 25 - assert score.breakdown["structure"] == 20 - assert score.breakdown["python_files"] == 15 + assert score.breakdown["dockerfile"] == 15 + assert score.breakdown["docker_compose"] == 10 + assert score.breakdown["dependency_file"] == 10 + assert score.breakdown["tests_directory"] == 10 + assert score.breakdown["python_files"] == 10 + assert score.breakdown["collected_tests"] == 15 + assert score.breakdown["docker_build"] == 15 - # RED: test_minimal_repo def test_minimal_repo(self) -> None: - """A repo with only README gets partial score.""" + """A repo with only README.md and one .py file gets 25 points.""" from repo_evaluator.scorer import HealthScorer report = RepoReport( repo_url="https://github.com/example/minimal", language="Python", structure_summary=["src"], - key_files={"README": True, "Dockerfile": False, "docker-compose": False}, + key_files=self._base_key_files(README=True), python_files=1, tests=TestResult(), + collected_tests=0, docker=DockerResult(present=False), ) scorer = HealthScorer() score = scorer.score(report) - assert score.value < 100 + assert score.value == 25 assert score.breakdown["readme"] == 15 - assert score.breakdown["docker"] == 0 - assert score.breakdown["tests"] == 0 + assert score.breakdown["python_files"] == 10 + assert all( + score.breakdown[k] == 0 + for k in ("dockerfile", "docker_compose", "dependency_file", "tests_directory", "collected_tests", "docker_build") + ) - # RED: test_empty_repo def test_empty_repo(self) -> None: """A completely empty repo gets 0.""" from repo_evaluator.scorer import HealthScorer @@ -65,9 +81,10 @@ def test_empty_repo(self) -> None: repo_url="https://github.com/example/empty", language=None, structure_summary=[], - key_files={"README": False, "Dockerfile": False, "docker-compose": False}, + key_files=self._base_key_files(), python_files=0, tests=TestResult(), + collected_tests=0, docker=DockerResult(present=False), ) scorer = HealthScorer() @@ -75,40 +92,80 @@ def test_empty_repo(self) -> None: assert score.value == 0 assert all(v == 0 for v in score.breakdown.values()) - # TRIANGULATE: single directory structure gets 10 - def test_single_dir_structure(self) -> None: - """A repo with exactly one top-level dir gets 10 for structure.""" + def test_partial_repo_no_docker_no_tests(self) -> None: + """README, pyproject.toml, and 5 Python files gives 45 points.""" from repo_evaluator.scorer import HealthScorer report = RepoReport( - repo_url="https://github.com/example/single-dir", + repo_url="https://github.com/example/partial", language="Python", structure_summary=["src"], - key_files={"README": True, "Dockerfile": False, "docker-compose": False}, - python_files=1, + key_files=self._base_key_files(README=True, dependency_file=True, tests_directory=True), + python_files=5, tests=TestResult(), + collected_tests=0, docker=DockerResult(present=False), ) scorer = HealthScorer() score = scorer.score(report) - assert score.breakdown["structure"] == 10 + assert score.value == 45 + assert score.breakdown["readme"] == 15 + assert score.breakdown["dependency_file"] == 10 assert score.breakdown["python_files"] == 10 - # TRIANGULATE: docker present but not buildable gets 10 - def test_docker_present_not_buildable(self) -> None: - """Docker present but failing build gives 10 points.""" + def test_docker_build_success(self) -> None: + """Docker build success awards full docker_build points.""" + from repo_evaluator.scorer import HealthScorer + + report = RepoReport( + repo_url="https://github.com/example/good-docker", + language="Python", + structure_summary=["src", "tests"], + key_files=self._base_key_files(Dockerfile=True), + python_files=3, + tests=TestResult(framework="pytest", count=2), + collected_tests=2, + docker=DockerResult(present=True, buildable=True), + ) + scorer = HealthScorer() + score = scorer.score(report) + assert score.breakdown["docker_build"] == 15 + assert score.breakdown["dockerfile"] == 15 + + def test_docker_present_but_not_buildable(self) -> None: + """Dockerfile present but build fails gives dockerfile points but not docker_build.""" from repo_evaluator.scorer import HealthScorer report = RepoReport( repo_url="https://github.com/example/bad-docker", language="Python", structure_summary=["src", "tests"], - key_files={"README": True, "Dockerfile": True, "docker-compose": False}, + key_files=self._base_key_files(Dockerfile=True), python_files=3, tests=TestResult(framework="pytest", count=2), + collected_tests=2, docker=DockerResult(present=True, buildable=False, error="bad base image"), ) scorer = HealthScorer() score = scorer.score(report) - assert score.breakdown["docker"] == 10 - assert score.value == 85 # 15+10+25+20+15 + assert score.breakdown["dockerfile"] == 15 + assert score.breakdown["docker_build"] == 0 + + def test_tests_directory_without_collected_tests(self) -> None: + """Tests directory present but no collected tests awards tests_directory only.""" + from repo_evaluator.scorer import HealthScorer + + report = RepoReport( + repo_url="https://github.com/example/tests-dir", + language="Python", + structure_summary=["src", "tests"], + key_files=self._base_key_files(tests_directory=True), + python_files=3, + tests=TestResult(framework="pytest", count=2), + collected_tests=0, + docker=DockerResult(present=False), + ) + scorer = HealthScorer() + score = scorer.score(report) + assert score.breakdown["tests_directory"] == 10 + assert score.breakdown["collected_tests"] == 0