name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
PYTHON_VERSION: "3.12"
COVERAGE_THRESHOLD: 80
jobs:
test:
name: Test & Coverage
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Install UV
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install dependencies
run: uv sync --frozen
- name: Run linting
run: |
echo "๐ Running code linting..."
uv run ruff check src/ tests/ --output-format=github
echo "๐จ Checking code formatting..."
uv run ruff format src/ tests/ --check
- name: Run unit tests with coverage
env:
OPENAI_API_KEY: test-key-for-mocks
VECTOR_DATABASE: qdrant
DATABASE_PROVIDER: qdrant
QDRANT_URL: http://localhost:6333
QDRANT_API_KEY: ""
QDRANT_COLLECTION_NAME: test_crawled_pages
QDRANT_EMBEDDING_MODEL: text-embedding-3-small
SEARXNG_URL: http://localhost:8081
USE_RERANKING: "false"
RERANKER_MODEL: BAAI/bge-reranker-v2-m3
CHUNK_SIZE: "2000"
CHUNK_OVERLAP: "200"
PYTHONPATH: ${{ github.workspace }}/src
TESTING: "true"
CI: "true"
run: |
echo "๐งช Running unit tests..."
uv run pytest tests/ -v --tb=short \
--cov=src \
--cov-report=json \
--cov-report=term-missing \
-m "not integration" \
--maxfail=10
- name: Check coverage threshold
run: |
if [ -f coverage.json ]; then
coverage_pct=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered'])")
echo "Coverage: ${coverage_pct}%"
if (( $(echo "$coverage_pct < ${{ env.COVERAGE_THRESHOLD }}" | bc -l) )); then
echo "โ Coverage ${coverage_pct}% is below threshold of ${{ env.COVERAGE_THRESHOLD }}%"
exit 1
else
echo "โ
Coverage ${coverage_pct}% meets threshold of ${{ env.COVERAGE_THRESHOLD }}%"
fi
fi
- name: Run integration tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY || 'test-key-for-mocks' }}
VECTOR_DATABASE: qdrant
DATABASE_PROVIDER: qdrant
QDRANT_URL: http://localhost:6333
QDRANT_API_KEY: ""
QDRANT_COLLECTION_NAME: test_crawled_pages
QDRANT_EMBEDDING_MODEL: text-embedding-3-small
SEARXNG_URL: http://localhost:8080
VALKEY_URL: redis://localhost:6379
USE_RERANKING: "false"
RERANKER_MODEL: BAAI/bge-reranker-v2-m3
CHUNK_SIZE: "2000"
CHUNK_OVERLAP: "200"
PYTHONPATH: ${{ github.workspace }}/src
TESTING: "true"
CI: "true"
run: |
echo "๐ Starting all test services..."
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
# Verify services are healthy
echo "๐ Checking service health..."
docker compose -f docker-compose.test.yml ps
# Wait a bit more for full initialization
sleep 15
# Test service connectivity
echo "๐ Testing service connectivity..."
curl -f http://localhost:6333/readyz || echo "โ ๏ธ Qdrant not ready"
curl -f http://localhost:8080/ || echo "โ ๏ธ SearXNG not ready"
# Run integration tests
echo "๐งช Running integration tests..."
uv run pytest tests/ -v --tb=short \
-m "integration" \
--maxfail=10 \
--timeout=120 \
|| TEST_EXIT_CODE=$?
# Show logs on failure
if [ ! -z "$TEST_EXIT_CODE" ] && [ "$TEST_EXIT_CODE" != "0" ]; then
echo "โ Integration tests failed, showing service logs..."
docker compose -f docker-compose.test.yml logs --tail=100
fi
# Stop services
echo "๐ Stopping test services..."
docker compose -f docker-compose.test.yml down -v
# Exit with test result
exit ${TEST_EXIT_CODE:-0}
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage.json
.coverage
retention-days: 7
- name: Comment coverage on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
try {
const coverage = JSON.parse(fs.readFileSync('coverage.json', 'utf8'));
const totalCoverage = coverage.totals.percent_covered;
const threshold = ${{ env.COVERAGE_THRESHOLD }};
const status = totalCoverage >= threshold ? 'โ
' : 'โ';
const message = `## ${status} Coverage Report
**Coverage**: ${totalCoverage.toFixed(2)}% (threshold: ${threshold}%)
| File | Coverage |
|------|----------|
${Object.entries(coverage.files)
.map(([file, data]) => `| ${file.replace(/^.*\/src\//, 'src/')} | ${data.summary.percent_covered.toFixed(1)}% |`)
.slice(0, 10)
.join('\n')}
[View full coverage report](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})`;
// Find existing comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('Coverage Report')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: message
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: message
});
}
} catch (error) {
console.log('Could not generate coverage comment:', error);
}