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`
+ ${repoOrder.map(repoId => { + const repo = repos.find(r => r.id === repoId); + const domSlug = repo ? domains.find(d => d.id === repo.domain_id)?.slug ?? "—" : "—"; + const snaps = snapByRepo[repoId]; // already sorted newest-first by API + return html`
+ + ${domSlug} + ${repo?.slug ?? repoId.slice(0,8)} + ${snaps.length} snapshot${snaps.length !== 1 ? "s" : ""} + +
+ ${Inputs.table(snaps.map(s => ({ + "Snapshot At": new Date(s.snapshot_at).toLocaleString(), + Packages: s.entry_count, + Source: s.source ?? "—", + ID: s.id.slice(0, 8) + "…", + })), {maxWidth: 700})} +
+
`; + })} +
`); +} +``` + ## Package Table ```js @@ -272,4 +317,16 @@ details[open] > .repo-summary::before { content: "▼"; } .repo-meta { font-size: 0.78rem; color: gray; } .repo-risk-badge { font-size: 0.75rem; font-weight: 600; color: #c62828; background: #fde8e8; border-radius: 4px; padding: 0.1rem 0.4rem; } .repo-pkg-table { padding: 0.5rem 0.75rem 0.75rem; } + +/* ── Snapshot history ─────────────────────────────────────────────────────── */ +.snap-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1.5rem; } +.snap-repo-block { background: var(--theme-background-alt); border-radius: 8px; } +.snap-repo-block[open] { border: 1px solid var(--theme-foreground-faint); } +.snap-repo-summary { cursor: pointer; padding: 0.65rem 0.9rem; display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; list-style: none; } +.snap-repo-summary::-webkit-details-marker { display: none; } +.snap-repo-summary::before { content: "▶"; font-size: 0.7rem; color: gray; flex-shrink: 0; } +details[open] > .snap-repo-summary::before { content: "▼"; } +.snap-repo-name { font-weight: 600; font-size: 0.9rem; font-family: monospace; } +.snap-meta { font-size: 0.78rem; color: gray; } +.snap-table-wrap { padding: 0.5rem 0.75rem 0.75rem; } diff --git a/dashboard/src/tasks.md b/dashboard/src/tasks.md index 43fe6c2..9c8e9a6 100644 --- a/dashboard/src/tasks.md +++ b/dashboard/src/tasks.md @@ -12,18 +12,20 @@ const taskState = (async function*() { while (true) { let data = [], ok = false; try { - const [rt, rw, rto] = await Promise.all([ + const [rt, rw, rto, rr] = await Promise.all([ fetch(`${API}/tasks/?limit=500`), fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), ]); - ok = rt.ok && rw.ok && rto.ok; + ok = rt.ok && rw.ok && rto.ok && rr.ok; if (ok) { - const [taskList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); + const [taskList, wsList, topicList, repoList] = await Promise.all([rt.json(), rw.json(), rto.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", + domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); data = taskList.map(t => ({ ...t, diff --git a/dashboard/src/techdept.md b/dashboard/src/techdept.md index 5ab532c..0fa2ef6 100644 --- a/dashboard/src/techdept.md +++ b/dashboard/src/techdept.md @@ -12,17 +12,19 @@ const tdState = (async function*() { while (true) { let data = [], ok = false; try { - const [rt, rw, rto] = await Promise.all([ + const [rt, rw, rto, rr] = await Promise.all([ fetch(`${API}/technical-debt/`), fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), ]); - ok = rt.ok && rw.ok && rto.ok; + ok = rt.ok && rw.ok && rto.ok && rr.ok; if (ok) { - const [tdList, wsList, topicList] = await Promise.all([rt.json(), rw.json(), rto.json()]); + const [tdList, wsList, topicList, repoList] = await Promise.all([rt.json(), rw.json(), rto.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 = tdList.map(t => ({ ...t, @@ -81,7 +83,7 @@ const filters = Generators.input(_filtersForm); const filtered = data.filter(t => (filters.status.length === 0 || filters.status.includes(t.status)) && (filters.severity.length === 0 || filters.severity.includes(t.severity)) && - (filters.domain.length === 0 || filters.domain.includes(t.domain)) && + (filters.domain.length === 0 || filters.domain.includes(t.domain_slug)) && (filters.debt_type.length === 0 || filters.debt_type.includes(t.debt_type)) ); ``` diff --git a/dashboard/src/todo.md b/dashboard/src/todo.md index 15c4132..769b182 100644 --- a/dashboard/src/todo.md +++ b/dashboard/src/todo.md @@ -14,21 +14,23 @@ const todoState = (async function*() { while (true) { let tasks = [], contribs = [], wsMap = {}, ok = false; try { - const [rt, rw, rto, rc] = await Promise.all([ + const [rt, rw, rto, rr, rc] = await Promise.all([ fetch(`${API}/tasks/?limit=500`), fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), fetch(`${API}/contributions/`), ]); - ok = rt.ok && rw.ok && rto.ok && rc.ok; + ok = rt.ok && rw.ok && rto.ok && rr.ok && rc.ok; if (ok) { - const [taskList, wsList, topicList, contribList] = await Promise.all([ - rt.json(), rw.json(), rto.json(), rc.json(), + const [taskList, wsList, topicList, repoList, contribList] = await Promise.all([ + rt.json(), rw.json(), rto.json(), rr.json(), rc.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", + domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", }])); tasks = taskList.map(t => ({ ...t, diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index d3c2608..b6b8f12 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -13,18 +13,20 @@ const wsState = (async function*() { while (true) { let data = [], openWs = [], ok = false; try { - const [rw, rt, rs] = await Promise.all([ + const [rw, rt, rr, rs] = await Promise.all([ fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/repos/`), fetch(`${API}/state/summary`), ]); - ok = rw.ok && rt.ok && rs.ok; + ok = rw.ok && rt.ok && rr.ok && rs.ok; if (ok) { - const [wsList, topicList, summary] = await Promise.all([rw.json(), rt.json(), rs.json()]); + const [wsList, topicList, repoList, summary] = await Promise.all([rw.json(), rt.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])); data = wsList.map(w => ({ ...w, - domain: topicMap[w.topic_id]?.domain_slug ?? "unknown", + domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", topic_title: topicMap[w.topic_id]?.title ?? "—", })); // open_workstreams from summary carry depends_on / blocks lists diff --git a/mcp_server/server.py b/mcp_server/server.py index f60059a..6db3e26 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -219,6 +219,7 @@ def create_workstream( description: str | None = None, owner: str | None = None, due_date: str | None = None, + repo_id: str | None = None, ) -> str: """Create a new workstream under a topic and emit a progress_event. @@ -229,6 +230,7 @@ def create_workstream( description: optional longer description owner: optional owner name due_date: optional ISO date string (YYYY-MM-DD) + repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001) """ if not slug: slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") @@ -240,6 +242,7 @@ def create_workstream( "owner": owner, "due_date": due_date, "status": "active", + "repo_id": repo_id, }) _post("/progress", { "topic_id": topic_id, @@ -975,8 +978,8 @@ def get_contributions( def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str: """Ingest a lockfile into the State Hub SBOM store for a repo. - Parses the lockfile and POSTs entries to /sbom/ingest/. Old entries - for the repo are replaced (snapshot strategy). + Parses the lockfile and POSTs entries to /sbom/ingest/. Each call creates + a new SBOMSnapshot; previous snapshots are retained as history. Args: repo_slug: Managed-repo slug (must be registered via register_repo) diff --git a/migrations/versions/a3b4c5d6e7f8_gems_pass3_sbom_snapshot.py b/migrations/versions/a3b4c5d6e7f8_gems_pass3_sbom_snapshot.py new file mode 100644 index 0000000..31f0983 --- /dev/null +++ b/migrations/versions/a3b4c5d6e7f8_gems_pass3_sbom_snapshot.py @@ -0,0 +1,93 @@ +"""GEMS Pass 3: add sbom_snapshots container entity + +Revision ID: a3b4c5d6e7f8 +Revises: f2a3b4c5d6e7 +Create Date: 2026-03-02 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "a3b4c5d6e7f8" +down_revision: Union[str, None] = "f2a3b4c5d6e7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ── Create sbom_snapshots table ──────────────────────────────────────────── + op.create_table( + "sbom_snapshots", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column( + "repo_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ), + sa.Column("snapshot_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("source", sa.String(200), nullable=True), + sa.Column("entry_count", sa.Integer, nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + # ── Add snapshot_id FK to sbom_entries (nullable during backfill) ────────── + op.add_column( + "sbom_entries", + sa.Column("snapshot_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_sbom_entry_snapshot_id", + "sbom_entries", "sbom_snapshots", + ["snapshot_id"], ["id"], + ondelete="RESTRICT", + ) + + # ── Backfill: create one snapshot per (repo_id, snapshot_at) group ───────── + op.execute(""" + INSERT INTO sbom_snapshots (id, repo_id, snapshot_at, source, entry_count, created_at) + SELECT + gen_random_uuid(), + repo_id, + snapshot_at, + 'backfill' AS source, + COUNT(*) AS entry_count, + MIN(created_at) AS created_at + FROM sbom_entries + GROUP BY repo_id, snapshot_at + """) + + # ── Assign snapshot_id to each entry ─────────────────────────────────────── + op.execute(""" + UPDATE sbom_entries e + SET snapshot_id = s.id + FROM sbom_snapshots s + WHERE s.repo_id = e.repo_id + AND s.snapshot_at = e.snapshot_at + """) + + # ── Make snapshot_id NOT NULL ────────────────────────────────────────────── + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM sbom_entries WHERE snapshot_id IS NULL) THEN + RAISE EXCEPTION 'GEMS Pass 3: sbom_entries rows with no snapshot assigned'; + END IF; + END $$; + """) + op.alter_column("sbom_entries", "snapshot_id", nullable=False) + op.create_index("ix_sbom_entries_snapshot_id", "sbom_entries", ["snapshot_id"]) + + +def downgrade() -> None: + op.drop_index("ix_sbom_entries_snapshot_id", table_name="sbom_entries") + op.drop_constraint("fk_sbom_entry_snapshot_id", "sbom_entries", type_="foreignkey") + op.drop_column("sbom_entries", "snapshot_id") + op.drop_table("sbom_snapshots") diff --git a/migrations/versions/e1f2a3b4c5d6_gems_pass1_domain_fk.py b/migrations/versions/e1f2a3b4c5d6_gems_pass1_domain_fk.py new file mode 100644 index 0000000..29596a5 --- /dev/null +++ b/migrations/versions/e1f2a3b4c5d6_gems_pass1_domain_fk.py @@ -0,0 +1,142 @@ +"""GEMS Pass 1: domain_id FK on extension_points/technical_debt, repo_id on contributions + +Revision ID: e1f2a3b4c5d6 +Revises: d3e4f5a6b7c8 +Create Date: 2026-03-02 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "e1f2a3b4c5d6" +down_revision: Union[str, None] = "d3e4f5a6b7c8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ── extension_points: add domain_id FK ──────────────────────────────────── + op.add_column( + "extension_points", + sa.Column("domain_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_ep_domain_id", + "extension_points", "domains", + ["domain_id"], ["id"], + ondelete="RESTRICT", + ) + + # Backfill from slug string + op.execute(""" + UPDATE extension_points ep + SET domain_id = d.id + FROM domains d + WHERE d.slug = ep.domain + """) + + # Safety check: abort if any rows remain unmatched + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM extension_points WHERE domain_id IS NULL) THEN + RAISE EXCEPTION + 'GEMS Pass 1: extension_points rows with unknown domain slug: %', + (SELECT string_agg(DISTINCT domain, ', ') + FROM extension_points WHERE domain_id IS NULL); + END IF; + END $$; + """) + + op.alter_column("extension_points", "domain_id", nullable=False) + op.drop_index("ix_extension_points_domain", table_name="extension_points") + op.drop_column("extension_points", "domain") + op.create_index("ix_extension_points_domain_id", "extension_points", ["domain_id"]) + + # ── technical_debt: add domain_id FK ────────────────────────────────────── + op.add_column( + "technical_debt", + sa.Column("domain_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_td_domain_id", + "technical_debt", "domains", + ["domain_id"], ["id"], + ondelete="RESTRICT", + ) + + op.execute(""" + UPDATE technical_debt td + SET domain_id = d.id + FROM domains d + WHERE d.slug = td.domain + """) + + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM technical_debt WHERE domain_id IS NULL) THEN + RAISE EXCEPTION + 'GEMS Pass 1: technical_debt rows with unknown domain slug: %', + (SELECT string_agg(DISTINCT domain, ', ') + FROM technical_debt WHERE domain_id IS NULL); + END IF; + END $$; + """) + + op.alter_column("technical_debt", "domain_id", nullable=False) + op.drop_index("ix_technical_debt_domain", table_name="technical_debt") + op.drop_column("technical_debt", "domain") + op.create_index("ix_technical_debt_domain_id", "technical_debt", ["domain_id"]) + + # ── contributions: add nullable repo_id FK ──────────────────────────────── + op.add_column( + "contributions", + sa.Column( + "repo_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + +def downgrade() -> None: + # contributions: drop repo_id + op.drop_column("contributions", "repo_id") + + # technical_debt: restore domain string + op.add_column( + "technical_debt", + sa.Column("domain", sa.String(50), nullable=True), + ) + op.execute(""" + UPDATE technical_debt td + SET domain = d.slug + FROM domains d + WHERE d.id = td.domain_id + """) + op.alter_column("technical_debt", "domain", nullable=False) + op.create_index("ix_technical_debt_domain", "technical_debt", ["domain"]) + op.drop_index("ix_technical_debt_domain_id", table_name="technical_debt") + op.drop_constraint("fk_td_domain_id", "technical_debt", type_="foreignkey") + op.drop_column("technical_debt", "domain_id") + + # extension_points: restore domain string + op.add_column( + "extension_points", + sa.Column("domain", sa.String(50), nullable=True), + ) + op.execute(""" + UPDATE extension_points ep + SET domain = d.slug + FROM domains d + WHERE d.id = ep.domain_id + """) + op.alter_column("extension_points", "domain", nullable=False) + op.create_index("ix_extension_points_domain", "extension_points", ["domain"]) + op.drop_index("ix_extension_points_domain_id", table_name="extension_points") + op.drop_constraint("fk_ep_domain_id", "extension_points", type_="foreignkey") + op.drop_column("extension_points", "domain_id") diff --git a/migrations/versions/f2a3b4c5d6e7_gems_pass2_workstream_repo_id.py b/migrations/versions/f2a3b4c5d6e7_gems_pass2_workstream_repo_id.py new file mode 100644 index 0000000..fc09b46 --- /dev/null +++ b/migrations/versions/f2a3b4c5d6e7_gems_pass2_workstream_repo_id.py @@ -0,0 +1,54 @@ +"""GEMS Pass 2: add repo_id FK to workstreams (ADR-001 alignment) + +Revision ID: f2a3b4c5d6e7 +Revises: e1f2a3b4c5d6 +Create Date: 2026-03-02 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "f2a3b4c5d6e7" +down_revision: Union[str, None] = "e1f2a3b4c5d6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "workstreams", + sa.Column("repo_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_workstream_repo_id", + "workstreams", "managed_repos", + ["repo_id"], ["id"], + ondelete="SET NULL", + ) + op.create_index("ix_workstreams_repo_id", "workstreams", ["repo_id"]) + + # Best-effort backfill: topic → domain → first repo (by created_at) + # Records with no repo in their domain remain NULL (requires manual resolution) + op.execute(""" + UPDATE workstreams ws + SET repo_id = sub.repo_id + FROM ( + SELECT DISTINCT ON (ws.id) + ws.id AS ws_id, + mr.id AS repo_id + FROM workstreams ws + JOIN topics t ON t.id = ws.topic_id + JOIN managed_repos mr ON mr.domain_id = t.domain_id + WHERE mr.status = 'active' + ORDER BY ws.id, mr.created_at + ) sub + WHERE ws.id = sub.ws_id + """) + + +def downgrade() -> None: + op.drop_index("ix_workstreams_repo_id", table_name="workstreams") + op.drop_constraint("fk_workstream_repo_id", "workstreams", type_="foreignkey") + op.drop_column("workstreams", "repo_id")