from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.models.managed_repo import ManagedRepo from api.models.sbom_entry import Ecosystem, SBOMEntry from api.schemas.sbom import ( LicenceGroup, LicenceReport, SBOMEntryRead, SBOMIngest, SBOMRepoView, ) router = APIRouter(prefix="/sbom", tags=["sbom"]) _COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} def _is_copyleft(spdx: str | None) -> bool: if not spdx: return False upper = spdx.upper() return any(pat in upper for pat in _COPYLEFT_PATTERNS) @router.post("/ingest/") async def ingest_sbom( body: SBOMIngest, session: AsyncSession = Depends(get_session), ) -> dict: """Replace the SBOM snapshot for a repo. Old entries are deleted first.""" repo = await _get_repo_by_slug(body.repo_slug, session) now = datetime.now(tz=timezone.utc) # Delete existing snapshot for this repo await session.execute(delete(SBOMEntry).where(SBOMEntry.repo_id == repo.id)) # Insert new entries for entry in body.entries: sbom = SBOMEntry( repo_id=repo.id, package_name=entry.package_name, package_version=entry.package_version, ecosystem=entry.ecosystem, license_spdx=entry.license_spdx, is_direct=entry.is_direct, is_dev=entry.is_dev, snapshot_at=now, created_at=now, ) session.add(sbom) repo.last_sbom_at = now if not repo.sbom_source: repo.sbom_source = "manual" await session.commit() return {"repo_slug": body.repo_slug, "ingested": len(body.entries), "snapshot_at": now.isoformat()} @router.get("/") async def list_sbom_entries( repo_slug: str | None = Query(None), ecosystem: Ecosystem | None = Query(None), license_spdx: str | None = Query(None), is_direct: bool | None = Query(None), is_dev: bool | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[SBOMEntryRead]: q = select(SBOMEntry).order_by(SBOMEntry.package_name) if repo_slug: repo = await _get_repo_by_slug(repo_slug, session) q = q.where(SBOMEntry.repo_id == repo.id) if ecosystem is not None: q = q.where(SBOMEntry.ecosystem == ecosystem) if license_spdx: q = q.where(SBOMEntry.license_spdx == license_spdx) if is_direct is not None: q = q.where(SBOMEntry.is_direct == is_direct) if is_dev is not None: q = q.where(SBOMEntry.is_dev == is_dev) result = await session.execute(q) return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] @router.get("/report/licences/", response_model=LicenceReport) async def licence_report( session: AsyncSession = Depends(get_session), ) -> LicenceReport: """Group SBOM entries by SPDX licence identifier, flag copyleft.""" rows = await session.execute( select(SBOMEntry, ManagedRepo.slug) .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) ) # Build: license_spdx → {count, repos set} groups: dict[str | None, dict] = {} copyleft_direct_count = 0 for entry, repo_slug in rows.all(): key = entry.license_spdx if key not in groups: groups[key] = {"count": 0, "repos": set()} groups[key]["count"] += 1 groups[key]["repos"].add(repo_slug) if _is_copyleft(key) and entry.is_direct and not entry.is_dev: copyleft_direct_count += 1 licence_groups = [ LicenceGroup( license_spdx=lic, count=info["count"], repos=sorted(info["repos"]), is_copyleft=_is_copyleft(lic), ) for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"]) ] return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) @router.get("/{repo_slug}/", response_model=SBOMRepoView) async def get_repo_sbom( repo_slug: str, session: AsyncSession = Depends(get_session), ) -> SBOMRepoView: repo = await _get_repo_by_slug(repo_slug, session) rows = await session.execute( select(SBOMEntry).where(SBOMEntry.repo_id == repo.id).order_by(SBOMEntry.package_name) ) entries = list(rows.scalars().all()) return SBOMRepoView( repo_slug=repo_slug, last_sbom_at=repo.last_sbom_at, entry_count=len(entries), entries=[SBOMEntryRead.model_validate(e) for e in entries], ) async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) repo = result.scalar_one_or_none() if repo is None: raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") return repo