Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
95 changes: 95 additions & 0 deletions .github/workflows/congrats.yml
Original file line number Diff line number Diff line change
@@ -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."
5 changes: 3 additions & 2 deletions src/modeldock/common/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 1 addition & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 1 addition & 3 deletions tests/unit/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
8 changes: 2 additions & 6 deletions tests/unit/test_sdk_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"}

Expand Down
Loading