feat(state-hub): v0.3 schema — contributions + sbom_entries migrations, models, schemas, routers

Migrations (chain: b1c2d3e4f5a6 → c2d3e4f5a6b7 → d3e4f5a6b7c8):
- c2d3e4f5a6b7: contributions table (contributiontype BR/FR/EP/UPR enum,
  contributionstatus 7-state lifecycle, FKs to topics/workstreams)
- d3e4f5a6b7c8: sbom_entries table (ecosystem enum, snapshot-based replacement),
  + sbom_source + last_sbom_at columns on managed_repos

New models: Contribution (ContributionType, ContributionStatus), SBOMEntry (Ecosystem)
Modified: ManagedRepo (sbom_source, last_sbom_at columns)

New routers:
- /contributions/ — CRUD + lifecycle-guarded PATCH /status + soft-delete (withdrawn)
- /sbom/ — ingest (replace snapshot), list, per-repo view, licence report

Modified:
- /state/summary now includes contribution_counts and licence_risk_count
- main.py: registers contributions + sbom routers; bumps version to 0.6.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 17:28:27 +01:00
parent 6edd39f4b8
commit 8d38110275
13 changed files with 672 additions and 3 deletions

146
api/routers/sbom.py Normal file
View File

@@ -0,0 +1,146 @@
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