feat(gems): three-pass schema migration aligning state-hub with GEMS

Implements CUST-WP-0007. Resolves inconsistencies I-1, I-2, I-5, I-6
identified in the GEMS audit (GenericEntityModellingSystem.md).

Pass 1 (e1f2a3b4c5d6): domain_id FK on extension_points and
technical_debt (replaces raw string column); repo_id FK on contributions.
Fixes domain-filtering bugs in EP/TD dashboard pages.

Pass 2 (f2a3b4c5d6e7): repo_id nullable FK on workstreams, aligning
the GEMS primary attachment with ADR-001 (repo > topic). Dashboard
pages updated to prefer repo->domain over topic->domain.

Pass 3 (a3b4c5d6e7f8): SBOMSnapshot container entity (GEMS Complex
between Repository and SBOMEntry). Ingest is now additive — each call
creates a new snapshot; history is retained. List/report endpoints
filter to latest snapshot per repo via _latest_snapshot_ids_subquery().
New endpoints: GET /sbom/snapshots/, GET /sbom/snapshots/{id}/.
Dashboard gains a Snapshot History section.

Also adds GEMS analysis artefacts: wiki/GEMS-StateHub-TypeRegistry.md,
wiki/GEMS-StateHub-SWOT.md, workplans/CUST-WP-0006 (analysis),
workplans/CUST-WP-0007 (migration, now completed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 23:39:17 +01:00
parent 62fbe884e3
commit fc87e26b4b
30 changed files with 675 additions and 84 deletions

View File

@@ -10,6 +10,7 @@ from api.models.extension_point import ExtensionPoint, EPStatus
from api.models.technical_debt import TechnicalDebt, TDStatus from api.models.technical_debt import TechnicalDebt, TDStatus
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.contribution import Contribution, ContributionType, ContributionStatus from api.models.contribution import Contribution, ContributionType, ContributionStatus
from api.models.sbom_snapshot import SBOMSnapshot
from api.models.sbom_entry import SBOMEntry, Ecosystem from api.models.sbom_entry import SBOMEntry, Ecosystem
__all__ = [ __all__ = [
@@ -25,5 +26,6 @@ __all__ = [
"TechnicalDebt", "TDStatus", "TechnicalDebt", "TDStatus",
"ManagedRepo", "ManagedRepo",
"Contribution", "ContributionType", "ContributionStatus", "Contribution", "ContributionType", "ContributionStatus",
"SBOMSnapshot",
"SBOMEntry", "Ecosystem", "SBOMEntry", "Ecosystem",
] ]

View File

@@ -50,6 +50,9 @@ class Contribution(Base, TimestampMixin):
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True 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( submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
@@ -60,3 +63,4 @@ class Contribution(Base, TimestampMixin):
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821

View File

@@ -25,7 +25,12 @@ class ExtensionPoint(Base, TimestampMixin):
ep_id: Mapped[str | None] = mapped_column( ep_id: Mapped[str | None] = mapped_column(
String(30), nullable=True, unique=True, index=True String(30), nullable=True, unique=True, index=True
) # human-readable ref, e.g. EP-CUST-001 ) # 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) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(String(500), 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 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 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", 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 ""

View File

@@ -34,3 +34,7 @@ class ManagedRepo(Base, TimestampMixin):
domain: Mapped["Domain"] = relationship( # noqa: F821 domain: Mapped["Domain"] = relationship( # noqa: F821
"Domain", back_populates="repos", lazy="selectin" "Domain", back_populates="repos", lazy="selectin"
) )
@property
def domain_slug(self) -> str:
return self.domain.slug if self.domain is not None else ""

View File

@@ -37,6 +37,12 @@ class SBOMEntry(Base):
license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True) license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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( snapshot_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False DateTime(timezone=True), nullable=False
) )
@@ -45,3 +51,6 @@ class SBOMEntry(Base):
) )
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821
"SBOMSnapshot", lazy="selectin", back_populates="entries"
)

View File

@@ -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"
)

View File

@@ -25,7 +25,12 @@ class TechnicalDebt(Base, TimestampMixin):
td_id: Mapped[str | None] = mapped_column( td_id: Mapped[str | None] = mapped_column(
String(30), nullable=True, unique=True, index=True String(30), nullable=True, unique=True, index=True
) # human-readable ref, e.g. TD-CUST-001 ) # 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) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True)
location: Mapped[str | None] = mapped_column(String(500), 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 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 topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
workstream: Mapped["Workstream"] = relationship("Workstream", 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 ""

View File

@@ -34,7 +34,15 @@ class Workstream(Base, TimestampMixin):
owner: Mapped[str | None] = mapped_column(String(100), nullable=True) owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, 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 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 tasks: Mapped[list["Task"]] = relationship( # noqa: F821
"Task", back_populates="workstream", lazy="selectin" "Task", back_populates="workstream", lazy="selectin"
) )

View File

@@ -12,10 +12,21 @@ from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
router = APIRouter(prefix="/extension-points", tags=["extension-points"]) router = APIRouter(prefix="/extension-points", tags=["extension-points"])
async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
"""Return the set of active domain slugs from the DB.""" """Resolve a domain slug to its UUID, raising 422 if unknown."""
rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) row = await session.execute(
return {r[0] for r in rows.all()} 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]) @router.get("/", response_model=list[EPRead])
@@ -27,7 +38,8 @@ async def list_eps(
) -> list[ExtensionPoint]: ) -> list[ExtensionPoint]:
q = select(ExtensionPoint) q = select(ExtensionPoint)
if domain: 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: if status:
q = q.where(ExtensionPoint.status == status) q = q.where(ExtensionPoint.status == status)
if ep_type: if ep_type:
@@ -42,13 +54,10 @@ async def create_ep(
body: EPCreate, body: EPCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> ExtensionPoint: ) -> ExtensionPoint:
valid_domains = await _get_valid_domain_slugs(session) domain_id = await _resolve_domain_id(body.domain, session)
if body.domain not in valid_domains: data = body.model_dump(exclude={"domain"})
raise HTTPException( data["domain_id"] = domain_id
status_code=422, ep = ExtensionPoint(**data)
detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}",
)
ep = ExtensionPoint(**body.model_dump())
session.add(ep) session.add(ep)
await session.commit() await session.commit()
await session.refresh(ep) await session.refresh(ep)

View File

@@ -1,18 +1,22 @@
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query 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 sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session from api.database import get_session
from api.models.managed_repo import ManagedRepo from api.models.managed_repo import ManagedRepo
from api.models.sbom_entry import Ecosystem, SBOMEntry from api.models.sbom_entry import Ecosystem, SBOMEntry
from api.models.sbom_snapshot import SBOMSnapshot
from api.schemas.sbom import ( from api.schemas.sbom import (
LicenceGroup, LicenceGroup,
LicenceReport, LicenceReport,
SBOMEntryRead, SBOMEntryRead,
SBOMIngest, SBOMIngest,
SBOMRepoView, SBOMRepoView,
SBOMSnapshotDetail,
SBOMSnapshotRead,
) )
router = APIRouter(prefix="/sbom", tags=["sbom"]) 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) 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/") @router.post("/ingest/")
async def ingest_sbom( async def ingest_sbom(
body: SBOMIngest, body: SBOMIngest,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> dict: ) -> 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) repo = await _get_repo_by_slug(body.repo_slug, session)
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=timezone.utc)
# Delete existing snapshot for this repo snap = SBOMSnapshot(
await session.execute(delete(SBOMEntry).where(SBOMEntry.repo_id == repo.id)) 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: for entry in body.entries:
sbom = SBOMEntry( sbom = SBOMEntry(
repo_id=repo.id, repo_id=repo.id,
snapshot_id=snap.id,
package_name=entry.package_name, package_name=entry.package_name,
package_version=entry.package_version, package_version=entry.package_version,
ecosystem=entry.ecosystem, ecosystem=entry.ecosystem,
@@ -59,7 +90,52 @@ async def ingest_sbom(
repo.sbom_source = "manual" repo.sbom_source = "manual"
await session.commit() 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("/") @router.get("/")
@@ -71,10 +147,21 @@ async def list_sbom_entries(
is_dev: bool | None = Query(None), is_dev: bool | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[SBOMEntryRead]: ) -> 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: if repo_slug:
repo = await _get_repo_by_slug(repo_slug, session) 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: if ecosystem is not None:
q = q.where(SBOMEntry.ecosystem == ecosystem) q = q.where(SBOMEntry.ecosystem == ecosystem)
if license_spdx: if license_spdx:
@@ -83,6 +170,7 @@ async def list_sbom_entries(
q = q.where(SBOMEntry.is_direct == is_direct) q = q.where(SBOMEntry.is_direct == is_direct)
if is_dev is not None: if is_dev is not None:
q = q.where(SBOMEntry.is_dev == is_dev) q = q.where(SBOMEntry.is_dev == is_dev)
q = q.order_by(SBOMEntry.package_name)
result = await session.execute(q) result = await session.execute(q)
return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]
@@ -91,12 +179,13 @@ async def list_sbom_entries(
async def licence_report( async def licence_report(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> LicenceReport: ) -> 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( rows = await session.execute(
select(SBOMEntry, ManagedRepo.slug) select(SBOMEntry, ManagedRepo.slug)
.join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) .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] = {} groups: dict[str | None, dict] = {}
copyleft_direct_count = 0 copyleft_direct_count = 0
for entry, repo_slug in rows.all(): for entry, repo_slug in rows.all():
@@ -125,9 +214,19 @@ async def get_repo_sbom(
repo_slug: str, repo_slug: str,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> SBOMRepoView: ) -> SBOMRepoView:
"""Return the latest snapshot entries for a specific repo."""
repo = await _get_repo_by_slug(repo_slug, session) 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( 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()) entries = list(rows.scalars().all())
return SBOMRepoView( return SBOMRepoView(

View File

@@ -255,14 +255,14 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
): ):
ws_per_domain[domain_id] = cnt 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( 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( 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 [ return [
@@ -271,8 +271,8 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
name=d.name, name=d.name,
repo_count=repo_counts.get(d.id, 0), repo_count=repo_counts.get(d.id, 0),
active_workstream_count=ws_per_domain.get(d.id, 0), active_workstream_count=ws_per_domain.get(d.id, 0),
ep_count=ep_counts.get(d.slug, 0), ep_count=ep_counts.get(d.id, 0),
td_count=td_counts.get(d.slug, 0), td_count=td_counts.get(d.id, 0),
) )
for d in domains for d in domains
] ]

View File

@@ -12,10 +12,21 @@ from api.schemas.technical_debt import TDCreate, TDRead, TDUpdate
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"]) router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
async def _get_valid_domain_slugs(session: AsyncSession) -> set[str]: async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
"""Return the set of active domain slugs from the DB.""" """Resolve a domain slug to its UUID, raising 422 if unknown."""
rows = await session.execute(select(Domain.slug).where(Domain.status == "active")) row = await session.execute(
return {r[0] for r in rows.all()} 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]) @router.get("/", response_model=list[TDRead])
@@ -28,7 +39,8 @@ async def list_td(
) -> list[TechnicalDebt]: ) -> list[TechnicalDebt]:
q = select(TechnicalDebt) q = select(TechnicalDebt)
if domain: 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: if status:
q = q.where(TechnicalDebt.status == status) q = q.where(TechnicalDebt.status == status)
if debt_type: if debt_type:
@@ -45,13 +57,10 @@ async def create_td(
body: TDCreate, body: TDCreate,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> TechnicalDebt: ) -> TechnicalDebt:
valid_domains = await _get_valid_domain_slugs(session) domain_id = await _resolve_domain_id(body.domain, session)
if body.domain not in valid_domains: data = body.model_dump(exclude={"domain"})
raise HTTPException( data["domain_id"] = domain_id
status_code=422, td = TechnicalDebt(**data)
detail=f"Unknown domain '{body.domain}'. Valid domains: {sorted(valid_domains)}",
)
td = TechnicalDebt(**body.model_dump())
session.add(td) session.add(td)
await session.commit() await session.commit()
await session.refresh(td) await session.refresh(td)

View File

@@ -14,12 +14,15 @@ router = APIRouter(prefix="/workstreams", tags=["workstreams"])
@router.get("/", response_model=list[WorkstreamRead]) @router.get("/", response_model=list[WorkstreamRead])
async def list_workstreams( async def list_workstreams(
topic_id: uuid.UUID | None = None, topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
status: WorkstreamStatus | None = None, status: WorkstreamStatus | None = None,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> list[Workstream]: ) -> list[Workstream]:
q = select(Workstream) q = select(Workstream)
if topic_id: if topic_id:
q = q.where(Workstream.topic_id == topic_id) q = q.where(Workstream.topic_id == topic_id)
if repo_id:
q = q.where(Workstream.repo_id == repo_id)
if status: if status:
q = q.where(Workstream.status == status) q = q.where(Workstream.status == status)
q = q.order_by(Workstream.created_at) q = q.order_by(Workstream.created_at)

View File

@@ -15,6 +15,7 @@ class ContributionCreate(BaseModel):
body_path: str | None = None body_path: str | None = None
related_topic_id: uuid.UUID | None = None related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None related_workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
notes: str | None = None notes: str | None = None
@@ -36,6 +37,7 @@ class ContributionRead(BaseModel):
body_path: str | None = None body_path: str | None = None
related_topic_id: uuid.UUID | None = None related_topic_id: uuid.UUID | None = None
related_workstream_id: uuid.UUID | None = None related_workstream_id: uuid.UUID | None = None
repo_id: uuid.UUID | None = None
submitted_at: datetime | None = None submitted_at: datetime | None = None
resolved_at: datetime | None = None resolved_at: datetime | None = None
notes: str | None = None notes: str | None = None

View File

@@ -10,7 +10,7 @@ VALID_PRIORITIES = {"low", "medium", "high", "critical"}
class EPCreate(BaseModel): class EPCreate(BaseModel):
ep_id: str | None = None ep_id: str | None = None
domain: str domain: str # slug; router resolves to domain_id FK
title: str title: str
description: str | None = None description: str | None = None
location: str | None = None location: str | None = None
@@ -36,7 +36,7 @@ class EPRead(BaseModel):
id: uuid.UUID id: uuid.UUID
ep_id: str | None = None ep_id: str | None = None
domain: str domain_slug: str # derived from domain relationship
title: str title: str
description: str | None = None description: str | None = None
location: str | None = None location: str | None = None

View File

@@ -26,6 +26,7 @@ class RepoRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
domain_id: uuid.UUID domain_id: uuid.UUID
domain_slug: str # derived from domain relationship
slug: str slug: str
name: str name: str
local_path: str | None = None local_path: str | None = None

View File

@@ -25,6 +25,7 @@ class SBOMEntryRead(BaseModel):
id: uuid.UUID id: uuid.UUID
repo_id: uuid.UUID repo_id: uuid.UUID
snapshot_id: uuid.UUID
package_name: str package_name: str
package_version: str | None = None package_version: str | None = None
ecosystem: Ecosystem ecosystem: Ecosystem
@@ -35,6 +36,29 @@ class SBOMEntryRead(BaseModel):
created_at: datetime 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): class LicenceGroup(BaseModel):
license_spdx: str | None license_spdx: str | None
count: int count: int

View File

@@ -10,7 +10,7 @@ VALID_SEVERITIES = {"low", "medium", "high", "critical"}
class TDCreate(BaseModel): class TDCreate(BaseModel):
td_id: str | None = None td_id: str | None = None
domain: str domain: str # slug; router resolves to domain_id FK
title: str title: str
description: str | None = None description: str | None = None
location: str | None = None location: str | None = None
@@ -36,7 +36,7 @@ class TDRead(BaseModel):
id: uuid.UUID id: uuid.UUID
td_id: str | None = None td_id: str | None = None
domain: str domain_slug: str # derived from domain relationship
title: str title: str
description: str | None = None description: str | None = None
location: str | None = None location: str | None = None

View File

@@ -15,6 +15,7 @@ class WorkstreamCreate(BaseModel):
status: WorkstreamStatus = WorkstreamStatus.active status: WorkstreamStatus = WorkstreamStatus.active
owner: str | None = None owner: str | None = None
due_date: date | None = None due_date: date | None = None
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
class WorkstreamUpdate(BaseModel): class WorkstreamUpdate(BaseModel):
@@ -23,12 +24,14 @@ class WorkstreamUpdate(BaseModel):
status: WorkstreamStatus | None = None status: WorkstreamStatus | None = None
owner: str | None = None owner: str | None = None
due_date: date | None = None due_date: date | None = None
repo_id: uuid.UUID | None = None
class WorkstreamRead(BaseModel): class WorkstreamRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
topic_id: uuid.UUID topic_id: uuid.UUID
repo_id: uuid.UUID | None = None
slug: str slug: str
title: str title: str
description: str | None = None description: str | None = None

View File

@@ -13,20 +13,23 @@ const depState = (async function*() {
while (true) { while (true) {
let wsMap = {}, edges = [], ok = false; let wsMap = {}, edges = [], ok = false;
try { try {
const [rw, rto, rs] = await Promise.all([ const [rw, rto, rr, rs] = await Promise.all([
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
fetch(`${API}/state/summary`), fetch(`${API}/state/summary`),
]); ]);
ok = rw.ok && rto.ok && rs.ok; ok = rw.ok && rto.ok && rr.ok && rs.ok;
if (ok) { if (ok) {
const [wsList, topicList, summary] = await Promise.all([ const [wsList, topicList, repoList, summary] = await Promise.all([
rw.json(), rto.json(), rs.json(), rw.json(), rto.json(), rr.json(), rs.json(),
]); ]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); 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, { wsMap = Object.fromEntries(wsList.map(w => [w.id, {
...w, ...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 // Build directed edge list from open_workstreams depends_on arrays
for (const ow of (summary.open_workstreams ?? [])) { for (const ow of (summary.open_workstreams ?? [])) {

View File

@@ -12,17 +12,19 @@ const epState = (async function*() {
while (true) { while (true) {
let data = [], ok = false; let data = [], ok = false;
try { try {
const [re, rw, rt] = await Promise.all([ const [re, rw, rt, rr] = await Promise.all([
fetch(`${API}/extension-points/`), fetch(`${API}/extension-points/`),
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
]); ]);
ok = re.ok && rw.ok && rt.ok; ok = re.ok && rw.ok && rt.ok && rr.ok;
if (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 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, { 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 => ({ data = epList.map(e => ({
...e, ...e,
@@ -81,7 +83,7 @@ const filters = Generators.input(_filtersForm);
const filtered = data.filter(e => const filtered = data.filter(e =>
(filters.status.length === 0 || filters.status.includes(e.status)) && (filters.status.length === 0 || filters.status.includes(e.status)) &&
(filters.priority.length === 0 || filters.priority.includes(e.priority)) && (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)) (filters.ep_type.length === 0 || filters.ep_type.includes(e.ep_type))
); );
``` ```

View File

@@ -8,23 +8,25 @@ const API = "http://127.0.0.1:8000";
```js ```js
// Fetch SBOM data on load // 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 { 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/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/sbom/report/licences/`).then(r => r.ok ? r.json() : {groups:[], copyleft_direct_count: 0}), 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}/repos/`).then(r => r.ok ? r.json() : []),
fetch(`${API}/domains/`).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 {} } catch {}
``` ```
```js ```js
const entries = _entries ?? []; const entries = _entries ?? [];
const report = _report ?? {groups: [], copyleft_direct_count: 0}; const report = _report ?? {groups: [], copyleft_direct_count: 0};
const repos = _repos ?? []; const repos = _repos ?? [];
const domains = _domains ?? []; const domains = _domains ?? [];
const groups = report.groups ?? []; const snapshots = _snapshots ?? [];
const groups = report.groups ?? [];
const riskCount = report.copyleft_direct_count ?? 0; const riskCount = report.copyleft_direct_count ?? 0;
// Domain + repo lookups // Domain + repo lookups
@@ -212,6 +214,49 @@ if (repoSections.length === 0) {
} }
``` ```
## Snapshot History
```js
if (snapshots.length === 0) {
display(html`<p style="color:gray">No snapshots recorded yet.</p>`);
} 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`<div class="snap-list">
${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`<details class="snap-repo-block">
<summary class="snap-repo-summary">
<span class="repo-domain-tag">${domSlug}</span>
<span class="snap-repo-name">${repo?.slug ?? repoId.slice(0,8)}</span>
<span class="snap-meta">${snaps.length} snapshot${snaps.length !== 1 ? "s" : ""}</span>
</summary>
<div class="snap-table-wrap">
${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})}
</div>
</details>`;
})}
</div>`);
}
```
## Package Table ## Package Table
```js ```js
@@ -272,4 +317,16 @@ details[open] > .repo-summary::before { content: "▼"; }
.repo-meta { font-size: 0.78rem; color: gray; } .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-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; } .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; }
</style> </style>

View File

@@ -12,18 +12,20 @@ const taskState = (async function*() {
while (true) { while (true) {
let data = [], ok = false; let data = [], ok = false;
try { try {
const [rt, rw, rto] = await Promise.all([ const [rt, rw, rto, rr] = await Promise.all([
fetch(`${API}/tasks/?limit=500`), fetch(`${API}/tasks/?limit=500`),
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
]); ]);
ok = rt.ok && rw.ok && rto.ok; ok = rt.ok && rw.ok && rto.ok && rr.ok;
if (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 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, { const wsMap = Object.fromEntries(wsList.map(w => [w.id, {
...w, ...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 => ({ data = taskList.map(t => ({
...t, ...t,

View File

@@ -12,17 +12,19 @@ const tdState = (async function*() {
while (true) { while (true) {
let data = [], ok = false; let data = [], ok = false;
try { try {
const [rt, rw, rto] = await Promise.all([ const [rt, rw, rto, rr] = await Promise.all([
fetch(`${API}/technical-debt/`), fetch(`${API}/technical-debt/`),
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
]); ]);
ok = rt.ok && rw.ok && rto.ok; ok = rt.ok && rw.ok && rto.ok && rr.ok;
if (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 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, { 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 => ({ data = tdList.map(t => ({
...t, ...t,
@@ -81,7 +83,7 @@ const filters = Generators.input(_filtersForm);
const filtered = data.filter(t => const filtered = data.filter(t =>
(filters.status.length === 0 || filters.status.includes(t.status)) && (filters.status.length === 0 || filters.status.includes(t.status)) &&
(filters.severity.length === 0 || filters.severity.includes(t.severity)) && (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)) (filters.debt_type.length === 0 || filters.debt_type.includes(t.debt_type))
); );
``` ```

View File

@@ -14,21 +14,23 @@ const todoState = (async function*() {
while (true) { while (true) {
let tasks = [], contribs = [], wsMap = {}, ok = false; let tasks = [], contribs = [], wsMap = {}, ok = false;
try { 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}/tasks/?limit=500`),
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
fetch(`${API}/contributions/`), 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) { if (ok) {
const [taskList, wsList, topicList, contribList] = await Promise.all([ const [taskList, wsList, topicList, repoList, contribList] = await Promise.all([
rt.json(), rw.json(), rto.json(), rc.json(), rt.json(), rw.json(), rto.json(), rr.json(), rc.json(),
]); ]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); 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, { wsMap = Object.fromEntries(wsList.map(w => [w.id, {
...w, ...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 => ({ tasks = taskList.map(t => ({
...t, ...t,

View File

@@ -13,18 +13,20 @@ const wsState = (async function*() {
while (true) { while (true) {
let data = [], openWs = [], ok = false; let data = [], openWs = [], ok = false;
try { try {
const [rw, rt, rs] = await Promise.all([ const [rw, rt, rr, rs] = await Promise.all([
fetch(`${API}/workstreams/`), fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`), fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
fetch(`${API}/state/summary`), fetch(`${API}/state/summary`),
]); ]);
ok = rw.ok && rt.ok && rs.ok; ok = rw.ok && rt.ok && rr.ok && rs.ok;
if (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 topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
data = wsList.map(w => ({ data = wsList.map(w => ({
...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 ?? "—", topic_title: topicMap[w.topic_id]?.title ?? "—",
})); }));
// open_workstreams from summary carry depends_on / blocks lists // open_workstreams from summary carry depends_on / blocks lists

View File

@@ -219,6 +219,7 @@ def create_workstream(
description: str | None = None, description: str | None = None,
owner: str | None = None, owner: str | None = None,
due_date: str | None = None, due_date: str | None = None,
repo_id: str | None = None,
) -> str: ) -> str:
"""Create a new workstream under a topic and emit a progress_event. """Create a new workstream under a topic and emit a progress_event.
@@ -229,6 +230,7 @@ def create_workstream(
description: optional longer description description: optional longer description
owner: optional owner name owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD) 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: if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
@@ -240,6 +242,7 @@ def create_workstream(
"owner": owner, "owner": owner,
"due_date": due_date, "due_date": due_date,
"status": "active", "status": "active",
"repo_id": repo_id,
}) })
_post("/progress", { _post("/progress", {
"topic_id": topic_id, "topic_id": topic_id,
@@ -975,8 +978,8 @@ def get_contributions(
def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str: def ingest_sbom_tool(repo_slug: str, lockfile_path: str) -> str:
"""Ingest a lockfile into the State Hub SBOM store for a repo. """Ingest a lockfile into the State Hub SBOM store for a repo.
Parses the lockfile and POSTs entries to /sbom/ingest/. Old entries Parses the lockfile and POSTs entries to /sbom/ingest/. Each call creates
for the repo are replaced (snapshot strategy). a new SBOMSnapshot; previous snapshots are retained as history.
Args: Args:
repo_slug: Managed-repo slug (must be registered via register_repo) repo_slug: Managed-repo slug (must be registered via register_repo)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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")