diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..cc763f3 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,129 @@ +name: Check + +on: + push: + branches: ["**"] + tags-ignore: ["v*.*.*"] + pull_request: + branches: [main] + schedule: + - cron: "27 4 * * 1" + +permissions: + contents: read + +concurrency: + group: check-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality-and-test: + name: Quality & tests (${{ matrix.os }}, py${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Sync dependencies + run: uv sync --extra dev --extra ollama + + - name: Ruff check + run: uv run ruff check src tests + + - name: Ruff format check + run: uv run ruff format --check src tests + + - name: Mypy + run: uv run mypy src + + - name: Bandit + run: uv run bandit -r src -c pyproject.toml + + - name: Pytest + run: uv run pytest + + coverage: + name: Coverage (Ubuntu, py3.12) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Sync dependencies + run: uv sync --extra dev --extra ollama + + - name: Run tests with coverage + run: | + uv run pytest --cov=modeldock --cov-report=term-missing \ + --cov-report=xml:coverage.xml --cov-report=html:htmlcov + + - name: Upload coverage XML + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-xml + path: coverage.xml + if-no-files-found: ignore + + - name: Upload HTML coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: htmlcov + path: htmlcov + if-no-files-found: ignore + + - name: Upload to Codecov + if: always() + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + codeql: + name: Analyze (CodeQL) + runs-on: ubuntu-latest + permissions: + security-events: write + packages: read + strategy: + fail-fast: false + matrix: + language: ["python"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/congrats.yml b/.github/workflows/congrats.yml new file mode 100644 index 0000000..61aa10b --- /dev/null +++ b/.github/workflows/congrats.yml @@ -0,0 +1,95 @@ +name: Congratulate on Merge + +# Automatically posts a congratulatory comment on merged Pull Requests. +# +# Features: +# - First-contribution detection via GitHub API +# - Label-based enhancements (first contribution, good first issue) +# - Bot author filtering (skips automated accounts) +# - Structured logging for audit trail +# +# Trigger: pull_request_target → closed (only merged PRs targeting main) +# Permissions: pull-requests: write, contents: read (least privilege) + +on: + pull_request_target: + types: [closed] + branches: [main] + +permissions: + pull-requests: write + contents: read + +jobs: + congratulate: + name: Congratulate contributor + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Log event + run: | + echo "PR #${{ github.event.pull_request.number }} merged by ${{ github.event.pull_request.user.login }}" + echo "Title: ${{ github.event.pull_request.title }}" + + - name: Skip bots + id: check + env: + AUTHOR: ${{ github.event.pull_request.user.login }} + AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} + run: | + if [ "$AUTHOR_TYPE" = "Bot" ]; then + echo "Author $AUTHOR is a bot — skipping congratulations." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Detect first contribution + if: steps.check.outputs.skip == 'false' + id: first + env: + GH_TOKEN: ${{ github.token }} + AUTHOR: ${{ github.event.pull_request.user.login }} + REPO: ${{ github.repository }} + run: | + COUNT=$(gh api "repos/$REPO/pulls?state=all&creator=$AUTHOR" \ + --jq 'length') + echo "Total PRs by $AUTHOR (incl. this one): $COUNT" + if [ "$COUNT" -le 1 ]; then + echo "first=true" >> "$GITHUB_OUTPUT" + else + echo "first=false" >> "$GITHUB_OUTPUT" + fi + + - name: Add labels + if: steps.check.outputs.skip == 'false' && steps.first.outputs.first == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + gh pr edit "$PR" --add-label "first contribution" + echo "Labeled PR #$PR as 'first contribution'." + + - name: Post congratulatory comment + if: steps.check.outputs.skip == 'false' + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + FIRST: ${{ steps.first.outputs.first }} + REPO: ${{ github.repository }} + run: | + if [ "$FIRST" = "true" ]; then + BODY=":tada: @$AUTHOR — congratulations on your **first contribution** to ModelDock! :tada + + Your PR has been merged. We're thrilled to have you in the community — thank you for helping make local AI model management better for everyone. + + If you're looking for what to tackle next, check out issues labeled \`good first issue\`." + else + BODY=":tada: Thanks @$AUTHOR — your PR has been merged! :tada + + Appreciate the contribution to ModelDock. If you spot anything else to improve, we'd love another PR." + fi + gh pr comment "$PR" --body "$BODY" + echo "Posted congratulatory comment to PR #$PR." diff --git a/src/modeldock/common/config.py b/src/modeldock/common/config.py index 79b94ca..d62030e 100644 --- a/src/modeldock/common/config.py +++ b/src/modeldock/common/config.py @@ -9,7 +9,7 @@ import os from pathlib import Path -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, Optional from pydantic import BaseModel, Field, field_validator @@ -81,7 +81,8 @@ def _toml_load(path: Path) -> Dict[str, Any]: import tomli with path.open("rb") as fh: - return cast(Dict[str, Any], tomli.load(fh)) + data: Dict[str, Any] = tomli.load(fh) + return data def _coerce_log_level(value: Any) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index d91430f..6895579 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -120,9 +120,7 @@ def list_installed(self) -> List[ModelRef]: return list(self._installed) def is_installed(self, ref: ModelRef) -> bool: - return any( - r.name == ref.name and r.tag == ref.tag for r in self._installed - ) + return any(r.name == ref.name and r.tag == ref.tag for r in self._installed) def pull(self, ref: ModelRef, progress: Any = None) -> PullResult: if self.pull_should_fail: diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index 4541904..34e1ccf 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -20,9 +20,7 @@ def test_download_pull_records_on_success( assert fake_cache.is_fresh(ref) -def test_download_pull_raises_on_failure( - fake_runtime: object, fake_cache: object -) -> None: +def test_download_pull_raises_on_failure(fake_runtime: object, fake_cache: object) -> None: fake_runtime.pull_should_fail = True svc = DownloadService(fake_runtime, fake_cache) with pytest.raises(DownloadError): diff --git a/tests/unit/test_sdk_api.py b/tests/unit/test_sdk_api.py index 5f58265..9e8b241 100644 --- a/tests/unit/test_sdk_api.py +++ b/tests/unit/test_sdk_api.py @@ -87,9 +87,7 @@ def test_sdk_remove_via_fake_manager() -> None: from tests.conftest import FakeCache, FakeRegistry, FakeRuntime runtime = FakeRuntime() - mgr = md.ModelManager( - runtime=runtime, registry=FakeRegistry(), cache=FakeCache() - ) + mgr = md.ModelManager(runtime=runtime, registry=FakeRegistry(), cache=FakeCache()) mgr.install("llama3") mgr.remove("llama3") assert not mgr.verify("llama3") @@ -99,9 +97,7 @@ def test_sdk_load_via_fake_manager() -> None: from tests.conftest import FakeCache, FakeRegistry, FakeRuntime runtime = FakeRuntime() - mgr = md.ModelManager( - runtime=runtime, registry=FakeRegistry(), cache=FakeCache() - ) + mgr = md.ModelManager(runtime=runtime, registry=FakeRegistry(), cache=FakeCache()) client = mgr.load("llama3", auto_install=True) assert client == {"client": "llama3:latest"}