diff --git a/api/models/__init__.py b/api/models/__init__.py index a21be32..2d02a4a 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -10,6 +10,7 @@ from api.models.extension_point import ExtensionPoint, EPStatus from api.models.technical_debt import TechnicalDebt, TDStatus from api.models.managed_repo import ManagedRepo from api.models.contribution import Contribution, ContributionType, ContributionStatus +from api.models.sbom_snapshot import SBOMSnapshot from api.models.sbom_entry import SBOMEntry, Ecosystem __all__ = [ @@ -25,5 +26,6 @@ __all__ = [ "TechnicalDebt", "TDStatus", "ManagedRepo", "Contribution", "ContributionType", "ContributionStatus", + "SBOMSnapshot", "SBOMEntry", "Ecosystem", ] diff --git a/api/models/contribution.py b/api/models/contribution.py index 285f7da..f6d731f 100644 --- a/api/models/contribution.py +++ b/api/models/contribution.py @@ -50,6 +50,9 @@ class Contribution(Base, TimestampMixin): related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True ) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True + ) submitted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) @@ -60,3 +63,4 @@ class Contribution(Base, TimestampMixin): topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/api/models/extension_point.py b/api/models/extension_point.py index 745e898..f34dcbb 100644 --- a/api/models/extension_point.py +++ b/api/models/extension_point.py @@ -25,7 +25,12 @@ class ExtensionPoint(Base, TimestampMixin): ep_id: Mapped[str | None] = mapped_column( String(30), nullable=True, unique=True, index=True ) # human-readable ref, e.g. EP-CUST-001 - domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) location: Mapped[str | None] = mapped_column(String(500), nullable=True) @@ -43,5 +48,10 @@ class ExtensionPoint(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True ) + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index 2a9e44a..1d68c38 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -34,3 +34,7 @@ class ManagedRepo(Base, TimestampMixin): domain: Mapped["Domain"] = relationship( # noqa: F821 "Domain", back_populates="repos", lazy="selectin" ) + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/api/models/sbom_entry.py b/api/models/sbom_entry.py index 2de4480..cece53e 100644 --- a/api/models/sbom_entry.py +++ b/api/models/sbom_entry.py @@ -37,6 +37,12 @@ class SBOMEntry(Base): license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True) is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + snapshot_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sbom_snapshots.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) snapshot_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) @@ -45,3 +51,6 @@ class SBOMEntry(Base): ) repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821 + "SBOMSnapshot", lazy="selectin", back_populates="entries" + ) diff --git a/api/models/sbom_snapshot.py b/api/models/sbom_snapshot.py new file mode 100644 index 0000000..538dd6e --- /dev/null +++ b/api/models/sbom_snapshot.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class SBOMSnapshot(Base): + """Container entity for a point-in-time SBOM scan of a repository (GEMS Complex).""" + __tablename__ = "sbom_snapshots" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + source: Mapped[str | None] = mapped_column(String(200), nullable=True) + entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + entries: Mapped[list["SBOMEntry"]] = relationship( # noqa: F821 + "SBOMEntry", lazy="select", back_populates="snapshot" + ) diff --git a/api/models/technical_debt.py b/api/models/technical_debt.py index f7c954d..7aee735 100644 --- a/api/models/technical_debt.py +++ b/api/models/technical_debt.py @@ -25,7 +25,12 @@ class TechnicalDebt(Base, TimestampMixin): td_id: Mapped[str | None] = mapped_column( String(30), nullable=True, unique=True, index=True ) # human-readable ref, e.g. TD-CUST-001 - domain: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) location: Mapped[str | None] = mapped_column(String(500), nullable=True) @@ -43,5 +48,10 @@ class TechnicalDebt(Base, TimestampMixin): UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True ) + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/api/models/workstream.py b/api/models/workstream.py index d2ddbb7..0882f6b 100644 --- a/api/models/workstream.py +++ b/api/models/workstream.py @@ -34,7 +34,15 @@ class Workstream(Base, TimestampMixin): owner: Mapped[str | None] = mapped_column(String(100), nullable=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821 + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 tasks: Mapped[list["Task"]] = relationship( # noqa: F821 "Task", back_populates="workstream", lazy="selectin" ) diff --git a/api/routers/extension_points.py b/api/routers/extension_points.py index 4de9d96..ee5883f 100644 --- a/api/routers/extension_points.py +++ b/api/routers/extension_points.py @@ -12,10 +12,21 @@ from api.schemas.extension_point import EPCreate, EPRead, EPUpdate router = APIRouter(prefix="/extension-points", tags=["extension-points"]) -async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: - """Return the set of active domain slugs from the DB.""" - rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) - return {r[0] for r in rows.all()} +async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID, raising 422 if unknown.""" + row = await session.execute( + select(Domain.id).where(Domain.slug == slug, Domain.status == "active") + ) + domain_id = row.scalar_one_or_none() + if domain_id is None: + valid = [r[0] for r in (await session.execute( + select(Domain.slug).where(Domain.status == "active") + )).all()] + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", + ) + return domain_id @router.get("/", response_model=list[EPRead]) @@ -27,7 +38,8 @@ async def list_eps( ) -> list[ExtensionPoint]: q = select(ExtensionPoint) if domain: - q = q.where(ExtensionPoint.domain == domain) + domain_id = await _resolve_domain_id(domain, session) + q = q.where(ExtensionPoint.domain_id == domain_id) if status: q = q.where(ExtensionPoint.status == status) if ep_type: @@ -42,13 +54,10 @@ async def create_ep( body: EPCreate, session: AsyncSession = Depends(get_session), ) -> ExtensionPoint: - valid_domains = await _get_valid_domain_slugs(session) - if body.domain not in valid_domains: - raise HTTPException( - status_code=422, - detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}", - ) - ep = ExtensionPoint(**body.model_dump()) + domain_id = await _resolve_domain_id(body.domain, session) + data = body.model_dump(exclude={"domain"}) + data["domain_id"] = domain_id + ep = ExtensionPoint(**data) session.add(ep) await session.commit() await session.refresh(ep) diff --git a/api/routers/sbom.py b/api/routers/sbom.py index a59f824..79e4a64 100644 --- a/api/routers/sbom.py +++ b/api/routers/sbom.py @@ -1,18 +1,22 @@ +import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import delete, func, select +from sqlalchemy import and_, 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.models.sbom_snapshot import SBOMSnapshot from api.schemas.sbom import ( LicenceGroup, LicenceReport, SBOMEntryRead, SBOMIngest, SBOMRepoView, + SBOMSnapshotDetail, + SBOMSnapshotRead, ) router = APIRouter(prefix="/sbom", tags=["sbom"]) @@ -27,22 +31,49 @@ def _is_copyleft(spdx: str | None) -> bool: return any(pat in upper for pat in _COPYLEFT_PATTERNS) +def _latest_snapshot_ids_subquery(): + """Subquery returning the latest SBOMSnapshot.id per repo.""" + max_at_sq = ( + select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at")) + .group_by(SBOMSnapshot.repo_id) + .subquery("max_snap_at") + ) + return ( + select(SBOMSnapshot.id) + .join( + max_at_sq, + and_( + SBOMSnapshot.repo_id == max_at_sq.c.repo_id, + SBOMSnapshot.snapshot_at == max_at_sq.c.max_at, + ), + ) + .subquery("latest_snap_ids") + ) + + @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.""" + """Create a new SBOM snapshot for a repo. Previous snapshots are retained.""" 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)) + snap = SBOMSnapshot( + repo_id=repo.id, + snapshot_at=now, + source="manual", + entry_count=len(body.entries), + created_at=now, + ) + session.add(snap) + await session.flush() # materialise snap.id before creating entries - # Insert new entries for entry in body.entries: sbom = SBOMEntry( repo_id=repo.id, + snapshot_id=snap.id, package_name=entry.package_name, package_version=entry.package_version, ecosystem=entry.ecosystem, @@ -59,7 +90,52 @@ async def ingest_sbom( repo.sbom_source = "manual" await session.commit() - return {"repo_slug": body.repo_slug, "ingested": len(body.entries), "snapshot_at": now.isoformat()} + return { + "repo_slug": body.repo_slug, + "snapshot_id": str(snap.id), + "ingested": len(body.entries), + "snapshot_at": now.isoformat(), + } + + +@router.get("/snapshots/", response_model=list[SBOMSnapshotRead]) +async def list_snapshots( + repo_slug: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[SBOMSnapshotRead]: + """List SBOM snapshots, newest first. Optionally filter by repo.""" + q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc()) + if repo_slug: + repo = await _get_repo_by_slug(repo_slug, session) + q = q.where(SBOMSnapshot.repo_id == repo.id) + result = await session.execute(q) + return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()] + + +@router.get("/snapshots/{snapshot_id}/", response_model=SBOMSnapshotDetail) +async def get_snapshot( + snapshot_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> SBOMSnapshotDetail: + """Get a snapshot with its full entry list.""" + snap = await session.get(SBOMSnapshot, snapshot_id) + if snap is None: + raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found") + result = await session.execute( + select(SBOMEntry) + .where(SBOMEntry.snapshot_id == snapshot_id) + .order_by(SBOMEntry.package_name) + ) + entries = list(result.scalars().all()) + return SBOMSnapshotDetail( + id=snap.id, + repo_id=snap.repo_id, + snapshot_at=snap.snapshot_at, + source=snap.source, + entry_count=snap.entry_count, + created_at=snap.created_at, + entries=[SBOMEntryRead.model_validate(e) for e in entries], + ) @router.get("/") @@ -71,10 +147,21 @@ async def list_sbom_entries( is_dev: bool | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[SBOMEntryRead]: - q = select(SBOMEntry).order_by(SBOMEntry.package_name) + """Return entries from the latest snapshot per repo (default) or filter by repo.""" if repo_slug: repo = await _get_repo_by_slug(repo_slug, session) - q = q.where(SBOMEntry.repo_id == repo.id) + latest_snap_id_sq = ( + select(SBOMSnapshot.id) + .where(SBOMSnapshot.repo_id == repo.id) + .order_by(SBOMSnapshot.snapshot_at.desc()) + .limit(1) + .scalar_subquery() + ) + q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq) + else: + latest_ids_sq = _latest_snapshot_ids_subquery() + q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id))) + if ecosystem is not None: q = q.where(SBOMEntry.ecosystem == ecosystem) if license_spdx: @@ -83,6 +170,7 @@ async def list_sbom_entries( q = q.where(SBOMEntry.is_direct == is_direct) if is_dev is not None: q = q.where(SBOMEntry.is_dev == is_dev) + q = q.order_by(SBOMEntry.package_name) result = await session.execute(q) return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] @@ -91,12 +179,13 @@ async def list_sbom_entries( async def licence_report( session: AsyncSession = Depends(get_session), ) -> LicenceReport: - """Group SBOM entries by SPDX licence identifier, flag copyleft.""" + """Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft.""" + latest_ids_sq = _latest_snapshot_ids_subquery() rows = await session.execute( select(SBOMEntry, ManagedRepo.slug) .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) + .where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id))) ) - # Build: license_spdx → {count, repos set} groups: dict[str | None, dict] = {} copyleft_direct_count = 0 for entry, repo_slug in rows.all(): @@ -125,9 +214,19 @@ async def get_repo_sbom( repo_slug: str, session: AsyncSession = Depends(get_session), ) -> SBOMRepoView: + """Return the latest snapshot entries for a specific repo.""" repo = await _get_repo_by_slug(repo_slug, session) + latest_snap_id_sq = ( + select(SBOMSnapshot.id) + .where(SBOMSnapshot.repo_id == repo.id) + .order_by(SBOMSnapshot.snapshot_at.desc()) + .limit(1) + .scalar_subquery() + ) rows = await session.execute( - select(SBOMEntry).where(SBOMEntry.repo_id == repo.id).order_by(SBOMEntry.package_name) + select(SBOMEntry) + .where(SBOMEntry.snapshot_id == latest_snap_id_sq) + .order_by(SBOMEntry.package_name) ) entries = list(rows.scalars().all()) return SBOMRepoView( diff --git a/api/routers/state.py b/api/routers/state.py index 0932650..715bb3f 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -255,14 +255,14 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: ): ws_per_domain[domain_id] = cnt - # EP counts per domain slug + # EP counts per domain id (via FK) ep_counts = {r[0]: r[1] for r in await session.execute( - select(ExtensionPoint.domain, func.count()).group_by(ExtensionPoint.domain) + select(ExtensionPoint.domain_id, func.count()).group_by(ExtensionPoint.domain_id) )} - # TD counts per domain slug + # TD counts per domain id (via FK) td_counts = {r[0]: r[1] for r in await session.execute( - select(TechnicalDebt.domain, func.count()).group_by(TechnicalDebt.domain) + select(TechnicalDebt.domain_id, func.count()).group_by(TechnicalDebt.domain_id) )} return [ @@ -271,8 +271,8 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: name=d.name, repo_count=repo_counts.get(d.id, 0), active_workstream_count=ws_per_domain.get(d.id, 0), - ep_count=ep_counts.get(d.slug, 0), - td_count=td_counts.get(d.slug, 0), + ep_count=ep_counts.get(d.id, 0), + td_count=td_counts.get(d.id, 0), ) for d in domains ] diff --git a/api/routers/technical_debt.py b/api/routers/technical_debt.py index d3fbb48..eb8a4de 100644 --- a/api/routers/technical_debt.py +++ b/api/routers/technical_debt.py @@ -12,10 +12,21 @@ from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) -async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: - """Return the set of active domain slugs from the DB.""" - rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) - return {r[0] for r in rows.all()} +async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID, raising 422 if unknown.""" + row = await session.execute( + select(Domain.id).where(Domain.slug == slug, Domain.status == "active") + ) + domain_id = row.scalar_one_or_none() + if domain_id is None: + valid = [r[0] for r in (await session.execute( + select(Domain.slug).where(Domain.status == "active") + )).all()] + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", + ) + return domain_id @router.get("/", response_model=list[TDRead]) @@ -28,7 +39,8 @@ async def list_td( ) -> list[TechnicalDebt]: q = select(TechnicalDebt) if domain: - q = q.where(TechnicalDebt.domain == domain) + domain_id = await _resolve_domain_id(domain, session) + q = q.where(TechnicalDebt.domain_id == domain_id) if status: q = q.where(TechnicalDebt.status == status) if debt_type: @@ -45,13 +57,10 @@ async def create_td( body: TDCreate, session: AsyncSession = Depends(get_session), ) -> TechnicalDebt: - valid_domains = await _get_valid_domain_slugs(session) - if body.domain not in valid_domains: - raise HTTPException( - status_code=422, - detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}", - ) - td = TechnicalDebt(**body.model_dump()) + domain_id = await _resolve_domain_id(body.domain, session) + data = body.model_dump(exclude={"domain"}) + data["domain_id"] = domain_id + td = TechnicalDebt(**data) session.add(td) await session.commit() await session.refresh(td) diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index d602802..32eb9b8 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -14,12 +14,15 @@ router = APIRouter(prefix="/workstreams", tags=["workstreams"]) @router.get("/", response_model=list[WorkstreamRead]) async def list_workstreams( topic_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, status: WorkstreamStatus | None = None, session: AsyncSession = Depends(get_session), ) -> list[Workstream]: q = select(Workstream) if topic_id: q = q.where(Workstream.topic_id == topic_id) + if repo_id: + q = q.where(Workstream.repo_id == repo_id) if status: q = q.where(Workstream.status == status) q = q.order_by(Workstream.created_at) diff --git a/api/schemas/contribution.py b/api/schemas/contribution.py index e037147..a241c09 100644 --- a/api/schemas/contribution.py +++ b/api/schemas/contribution.py @@ -15,6 +15,7 @@ class ContributionCreate(BaseModel): body_path: str | None = None related_topic_id: uuid.UUID | None = None related_workstream_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None notes: str | None = None @@ -36,6 +37,7 @@ class ContributionRead(BaseModel): body_path: str | None = None related_topic_id: uuid.UUID | None = None related_workstream_id: uuid.UUID | None = None + repo_id: uuid.UUID | None = None submitted_at: datetime | None = None resolved_at: datetime | None = None notes: str | None = None diff --git a/api/schemas/extension_point.py b/api/schemas/extension_point.py index 0cdc53e..23f6366 100644 --- a/api/schemas/extension_point.py +++ b/api/schemas/extension_point.py @@ -10,7 +10,7 @@ VALID_PRIORITIES = {"low", "medium", "high", "critical"} class EPCreate(BaseModel): ep_id: str | None = None - domain: str + domain: str # slug; router resolves to domain_id FK title: str description: str | None = None location: str | None = None @@ -36,7 +36,7 @@ class EPRead(BaseModel): id: uuid.UUID ep_id: str | None = None - domain: str + domain_slug: str # derived from domain relationship title: str description: str | None = None location: str | None = None diff --git a/api/schemas/managed_repo.py b/api/schemas/managed_repo.py index 3f4d9c3..1aa8582 100644 --- a/api/schemas/managed_repo.py +++ b/api/schemas/managed_repo.py @@ -26,6 +26,7 @@ class RepoRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID domain_id: uuid.UUID + domain_slug: str # derived from domain relationship slug: str name: str local_path: str | None = None diff --git a/api/schemas/sbom.py b/api/schemas/sbom.py index b2b3670..51f00d7 100644 --- a/api/schemas/sbom.py +++ b/api/schemas/sbom.py @@ -25,6 +25,7 @@ class SBOMEntryRead(BaseModel): id: uuid.UUID repo_id: uuid.UUID + snapshot_id: uuid.UUID package_name: str package_version: str | None = None ecosystem: Ecosystem @@ -35,6 +36,29 @@ class SBOMEntryRead(BaseModel): created_at: datetime +class SBOMSnapshotRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + snapshot_at: datetime + source: str | None = None + entry_count: int + created_at: datetime + + +class SBOMSnapshotDetail(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + snapshot_at: datetime + source: str | None = None + entry_count: int + created_at: datetime + entries: list[SBOMEntryRead] = [] + + class LicenceGroup(BaseModel): license_spdx: str | None count: int diff --git a/api/schemas/technical_debt.py b/api/schemas/technical_debt.py index 615240d..f5d920e 100644 --- a/api/schemas/technical_debt.py +++ b/api/schemas/technical_debt.py @@ -10,7 +10,7 @@ VALID_SEVERITIES = {"low", "medium", "high", "critical"} class TDCreate(BaseModel): td_id: str | None = None - domain: str + domain: str # slug; router resolves to domain_id FK title: str description: str | None = None location: str | None = None @@ -36,7 +36,7 @@ class TDRead(BaseModel): id: uuid.UUID td_id: str | None = None - domain: str + domain_slug: str # derived from domain relationship title: str description: str | None = None location: str | None = None diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index 08058d9..8e0ccc0 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -15,6 +15,7 @@ class WorkstreamCreate(BaseModel): status: WorkstreamStatus = WorkstreamStatus.active owner: str | None = None due_date: date | None = None + repo_id: uuid.UUID | None = None # GEMS primary: the owning repository class WorkstreamUpdate(BaseModel): @@ -23,12 +24,14 @@ class WorkstreamUpdate(BaseModel): status: WorkstreamStatus | None = None owner: str | None = None due_date: date | None = None + repo_id: uuid.UUID | None = None class WorkstreamRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID topic_id: uuid.UUID + repo_id: uuid.UUID | None = None slug: str title: str description: str | None = None diff --git a/dashboard/src/dependencies.md b/dashboard/src/dependencies.md index de7164f..8c15fde 100644 --- a/dashboard/src/dependencies.md +++ b/dashboard/src/dependencies.md @@ -13,20 +13,23 @@ const depState = (async function*() { while (true) { let wsMap = {}, edges = [], ok = false; try { - const [rw, rto, rs] = await Promise.all([ + const [rw, rto, rr, rs] = await Promise.all([ fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), fetch(`${API}/state/summary`), ]); - ok = rw.ok && rto.ok && rs.ok; + ok = rw.ok && rto.ok && rr.ok && rs.ok; if (ok) { - const [wsList, topicList, summary] = await Promise.all([ - rw.json(), rto.json(), rs.json(), + const [wsList, topicList, repoList, summary] = await Promise.all([ + rw.json(), rto.json(), rr.json(), rs.json(), ]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); wsMap = Object.fromEntries(wsList.map(w => [w.id, { ...w, - domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", + // Prefer repo→domain (GEMS primary); fall back to topic→domain + domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); // Build directed edge list from open_workstreams depends_on arrays for (const ow of (summary.open_workstreams ?? [])) { diff --git a/dashboard/src/extensions.md b/dashboard/src/extensions.md index 181b2ff..9563089 100644 --- a/dashboard/src/extensions.md +++ b/dashboard/src/extensions.md @@ -12,17 +12,19 @@ const epState = (async function*() { while (true) { let data = [], ok = false; try { - const [re, rw, rt] = await Promise.all([ + const [re, rw, rt, rr] = await Promise.all([ fetch(`${API}/extension-points/`), fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), ]); - ok = re.ok && rw.ok && rt.ok; + ok = re.ok && rw.ok && rt.ok && rr.ok; if (ok) { - const [epList, wsList, topicList] = await Promise.all([re.json(), rw.json(), rt.json()]); + const [epList, wsList, topicList, repoList] = await Promise.all([re.json(), rw.json(), rt.json(), rr.json()]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); const wsMap = Object.fromEntries(wsList.map(w => [w.id, { - ...w, domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", + ...w, domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); data = epList.map(e => ({ ...e, @@ -81,7 +83,7 @@ const filters = Generators.input(_filtersForm); const filtered = data.filter(e => (filters.status.length === 0 || filters.status.includes(e.status)) && (filters.priority.length === 0 || filters.priority.includes(e.priority)) && - (filters.domain.length === 0 || filters.domain.includes(e.domain)) && + (filters.domain.length === 0 || filters.domain.includes(e.domain_slug)) && (filters.ep_type.length === 0 || filters.ep_type.includes(e.ep_type)) ); ``` diff --git a/dashboard/src/sbom.md b/dashboard/src/sbom.md index 9b7716b..824343f 100644 --- a/dashboard/src/sbom.md +++ b/dashboard/src/sbom.md @@ -8,23 +8,25 @@ const API = "http://127.0.0.1:8000"; ```js // Fetch SBOM data on load -let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [], _domains = []; +let _entries = [], _report = {groups: [], copyleft_direct_count: 0}, _repos = [], _domains = [], _snapshots = []; try { - [_entries, _report, _repos, _domains] = await Promise.all([ + [_entries, _report, _repos, _domains, _snapshots] = await Promise.all([ fetch(`${API}/sbom/`).then(r => r.ok ? r.json() : []), fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}), fetch(`${API}/repos/`).then(r => r.ok ? r.json() : []), fetch(`${API}/domains/`).then(r => r.ok ? r.json() : []), + fetch(`${API}/sbom/snapshots/`).then(r => r.ok ? r.json() : []), ]); } catch {} ``` ```js -const entries = _entries ?? []; -const report = _report ?? {groups: [], copyleft_direct_count: 0}; -const repos = _repos ?? []; -const domains = _domains ?? []; -const groups = report.groups ?? []; +const entries = _entries ?? []; +const report = _report ?? {groups: [], copyleft_direct_count: 0}; +const repos = _repos ?? []; +const domains = _domains ?? []; +const snapshots = _snapshots ?? []; +const groups = report.groups ?? []; const riskCount = report.copyleft_direct_count ?? 0; // Domain + repo lookups @@ -212,6 +214,49 @@ if (repoSections.length === 0) { } ``` +## Snapshot History + +```js +if (snapshots.length === 0) { + display(html`
No snapshots recorded yet.
`); +} else { + // Group by repo, sort newest first within each group + const snapByRepo = {}; + for (const s of snapshots) { + (snapByRepo[s.repo_id] = snapByRepo[s.repo_id] ?? []).push(s); + } + + const repoOrder = Object.keys(snapByRepo).sort((a, b) => { + const ra = repos.find(r => r.id === a); + const rb = repos.find(r => r.id === b); + return (ra?.slug ?? a).localeCompare(rb?.slug ?? b); + }); + + display(html`