From 8d38110275d492a2b808e421d528147fda53e29b Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 28 Feb 2026 17:28:27 +0100 Subject: [PATCH] =?UTF-8?q?feat(state-hub):=20v0.3=20schema=20=E2=80=94=20?= =?UTF-8?q?contributions=20+=20sbom=5Fentries=20migrations,=20models,=20sc?= =?UTF-8?q?hemas,=20routers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrations (chain: b1c2d3e4f5a6 → c2d3e4f5a6b7 → d3e4f5a6b7c8): - c2d3e4f5a6b7: contributions table (contributiontype BR/FR/EP/UPR enum, contributionstatus 7-state lifecycle, FKs to topics/workstreams) - d3e4f5a6b7c8: sbom_entries table (ecosystem enum, snapshot-based replacement), + sbom_source + last_sbom_at columns on managed_repos New models: Contribution (ContributionType, ContributionStatus), SBOMEntry (Ecosystem) Modified: ManagedRepo (sbom_source, last_sbom_at columns) New routers: - /contributions/ — CRUD + lifecycle-guarded PATCH /status + soft-delete (withdrawn) - /sbom/ — ingest (replace snapshot), list, per-repo view, licence report Modified: - /state/summary now includes contribution_counts and licence_risk_count - main.py: registers contributions + sbom routers; bumps version to 0.6.0 Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 6 +- api/models/__init__.py | 4 + api/models/contribution.py | 62 ++++++++ api/models/managed_repo.py | 7 +- api/models/sbom_entry.py | 47 ++++++ api/routers/contributions.py | 147 ++++++++++++++++++ api/routers/sbom.py | 146 +++++++++++++++++ api/routers/state.py | 31 ++++ api/schemas/contribution.py | 43 +++++ api/schemas/sbom.py | 54 +++++++ api/schemas/state.py | 2 + .../c2d3e4f5a6b7_v0_3_contributions.py | 66 ++++++++ .../d3e4f5a6b7c8_v0_3_sbom_entries.py | 60 +++++++ 13 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 api/models/contribution.py create mode 100644 api/models/sbom_entry.py create mode 100644 api/routers/contributions.py create mode 100644 api/routers/sbom.py create mode 100644 api/schemas/contribution.py create mode 100644 api/schemas/sbom.py create mode 100644 migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py create mode 100644 migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py diff --git a/api/main.py b/api/main.py index 2ca2dad..cd9e41e 100644 --- a/api/main.py +++ b/api/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos +from api.routers import domains, repos, contributions, sbom @asynccontextmanager @@ -17,7 +17,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="Custodian State Hub", description="Local-first state API for the Custodian agent system.", - version="0.5.0", + version="0.6.0", lifespan=lifespan, ) @@ -38,6 +38,8 @@ app.include_router(decisions.router) app.include_router(extension_points.router) app.include_router(technical_debt.router) app.include_router(progress.router) +app.include_router(contributions.router) +app.include_router(sbom.router) app.include_router(state.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index 3eaafa3..a21be32 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -9,6 +9,8 @@ from api.models.progress_event import ProgressEvent 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_entry import SBOMEntry, Ecosystem __all__ = [ "Base", @@ -22,4 +24,6 @@ __all__ = [ "ExtensionPoint", "EPStatus", "TechnicalDebt", "TDStatus", "ManagedRepo", + "Contribution", "ContributionType", "ContributionStatus", + "SBOMEntry", "Ecosystem", ] diff --git a/api/models/contribution.py b/api/models/contribution.py new file mode 100644 index 0000000..285f7da --- /dev/null +++ b/api/models/contribution.py @@ -0,0 +1,62 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class ContributionType(str, enum.Enum): + br = "br" + fr = "fr" + ep = "ep" + upr = "upr" + + +class ContributionStatus(str, enum.Enum): + draft = "draft" + submitted = "submitted" + acknowledged = "acknowledged" + accepted = "accepted" + rejected = "rejected" + merged = "merged" + withdrawn = "withdrawn" + + +class Contribution(Base, TimestampMixin): + __tablename__ = "contributions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + type: Mapped[ContributionType] = mapped_column( + Enum(ContributionType, name="contributiontype"), nullable=False + ) + target_org: Mapped[str | None] = mapped_column(String(200), nullable=True) + target_repo: Mapped[str | None] = mapped_column(String(200), nullable=True) + slug: Mapped[str | None] = mapped_column(String(200), nullable=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + status: Mapped[ContributionStatus] = mapped_column( + Enum(ContributionStatus, name="contributionstatus"), + nullable=False, default=ContributionStatus.draft, + ) + body_path: Mapped[str | None] = mapped_column(Text, nullable=True) + related_topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + related_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True + ) + submitted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821 diff --git a/api/models/managed_repo.py b/api/models/managed_repo.py index dcf6f75..2a9e44a 100644 --- a/api/models/managed_repo.py +++ b/api/models/managed_repo.py @@ -1,6 +1,7 @@ import uuid +from datetime import datetime -from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -25,6 +26,10 @@ class ManagedRepo(Base, TimestampMixin): topic_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True ) + sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True) + last_sbom_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) domain: Mapped["Domain"] = relationship( # noqa: F821 "Domain", back_populates="repos", lazy="selectin" diff --git a/api/models/sbom_entry.py b/api/models/sbom_entry.py new file mode 100644 index 0000000..2de4480 --- /dev/null +++ b/api/models/sbom_entry.py @@ -0,0 +1,47 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class Ecosystem(str, enum.Enum): + python = "python" + node = "node" + rust = "rust" + go = "go" + java = "java" + other = "other" + + +class SBOMEntry(Base): + """Snapshot-based SBOM entry — no updated_at; new ingest replaces old rows.""" + __tablename__ = "sbom_entries" + + 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, + ) + package_name: Mapped[str] = mapped_column(String(300), nullable=False) + package_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + ecosystem: Mapped[Ecosystem] = mapped_column( + Enum(Ecosystem, name="ecosystem"), nullable=False + ) + 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_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/api/routers/contributions.py b/api/routers/contributions.py new file mode 100644 index 0000000..1fb530d --- /dev/null +++ b/api/routers/contributions.py @@ -0,0 +1,147 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.contribution import Contribution, ContributionStatus, ContributionType +from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch + +router = APIRouter(prefix="/contributions", tags=["contributions"]) + +# Valid forward transitions in the lifecycle +_VALID_TRANSITIONS: dict[ContributionStatus, set[ContributionStatus]] = { + ContributionStatus.draft: { + ContributionStatus.submitted, + ContributionStatus.withdrawn, + }, + ContributionStatus.submitted: { + ContributionStatus.acknowledged, + ContributionStatus.rejected, + ContributionStatus.withdrawn, + }, + ContributionStatus.acknowledged: { + ContributionStatus.accepted, + ContributionStatus.rejected, + ContributionStatus.withdrawn, + }, + ContributionStatus.accepted: { + ContributionStatus.merged, + ContributionStatus.withdrawn, + }, + ContributionStatus.rejected: set(), + ContributionStatus.merged: set(), + ContributionStatus.withdrawn: set(), +} + + +@router.get("/", response_model=list[ContributionRead]) +async def list_contributions( + type: ContributionType | None = Query(None), + status: ContributionStatus | None = Query(None), + target_repo: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[Contribution]: + q = select(Contribution).order_by(Contribution.created_at.desc()) + if type is not None: + q = q.where(Contribution.type == type) + if status is not None: + q = q.where(Contribution.status == status) + if target_repo: + q = q.where(Contribution.target_repo == target_repo) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED) +async def create_contribution( + body: ContributionCreate, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = Contribution( + type=body.type, + target_org=body.target_org, + target_repo=body.target_repo, + slug=body.slug, + title=body.title, + body_path=body.body_path, + related_topic_id=body.related_topic_id, + related_workstream_id=body.related_workstream_id, + notes=body.notes, + status=ContributionStatus.draft, + ) + session.add(contrib) + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.get("/{contribution_id}/", response_model=ContributionRead) +async def get_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Contribution: + return await _get_or_404(contribution_id, session) + + +@router.patch("/{contribution_id}/status", response_model=ContributionRead) +async def patch_contribution_status( + contribution_id: uuid.UUID, + body: ContributionStatusPatch, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = await _get_or_404(contribution_id, session) + allowed = _VALID_TRANSITIONS.get(contrib.status, set()) + if body.status not in allowed: + raise HTTPException( + status_code=422, + detail=( + f"Cannot transition from '{contrib.status}' to '{body.status}'. " + f"Allowed: {[s.value for s in allowed] or 'none (terminal state)'}" + ), + ) + contrib.status = body.status + if body.notes: + contrib.notes = body.notes + now = datetime.now(tz=timezone.utc) + if body.status == ContributionStatus.submitted: + contrib.submitted_at = now + elif body.status in ( + ContributionStatus.accepted, ContributionStatus.rejected, + ContributionStatus.merged, ContributionStatus.withdrawn, + ): + contrib.resolved_at = now + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.delete("/{contribution_id}/", status_code=status.HTTP_204_NO_CONTENT) +async def withdraw_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Soft-delete: sets status to 'withdrawn'.""" + contrib = await _get_or_404(contribution_id, session) + if contrib.status == ContributionStatus.withdrawn: + return # idempotent + if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected): + raise HTTPException( + status_code=409, + detail=f"Cannot withdraw a contribution with status '{contrib.status}'.", + ) + contrib.status = ContributionStatus.withdrawn + contrib.resolved_at = datetime.now(tz=timezone.utc) + await session.commit() + + +async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution: + result = await session.execute( + select(Contribution).where(Contribution.id == contribution_id) + ) + contrib = result.scalar_one_or_none() + if contrib is None: + raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found") + return contrib diff --git a/api/routers/sbom.py b/api/routers/sbom.py new file mode 100644 index 0000000..a59f824 --- /dev/null +++ b/api/routers/sbom.py @@ -0,0 +1,146 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.sbom_entry import Ecosystem, SBOMEntry +from api.schemas.sbom import ( + LicenceGroup, + LicenceReport, + SBOMEntryRead, + SBOMIngest, + SBOMRepoView, +) + +router = APIRouter(prefix="/sbom", tags=["sbom"]) + +_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"} + + +def _is_copyleft(spdx: str | None) -> bool: + if not spdx: + return False + upper = spdx.upper() + return any(pat in upper for pat in _COPYLEFT_PATTERNS) + + +@router.post("/ingest/") +async def ingest_sbom( + body: SBOMIngest, + session: AsyncSession = Depends(get_session), +) -> dict: + """Replace the SBOM snapshot for a repo. Old entries are deleted first.""" + repo = await _get_repo_by_slug(body.repo_slug, session) + now = datetime.now(tz=timezone.utc) + + # Delete existing snapshot for this repo + await session.execute(delete(SBOMEntry).where(SBOMEntry.repo_id == repo.id)) + + # Insert new entries + for entry in body.entries: + sbom = SBOMEntry( + repo_id=repo.id, + package_name=entry.package_name, + package_version=entry.package_version, + ecosystem=entry.ecosystem, + license_spdx=entry.license_spdx, + is_direct=entry.is_direct, + is_dev=entry.is_dev, + snapshot_at=now, + created_at=now, + ) + session.add(sbom) + + repo.last_sbom_at = now + if not repo.sbom_source: + repo.sbom_source = "manual" + + await session.commit() + return {"repo_slug": body.repo_slug, "ingested": len(body.entries), "snapshot_at": now.isoformat()} + + +@router.get("/") +async def list_sbom_entries( + repo_slug: str | None = Query(None), + ecosystem: Ecosystem | None = Query(None), + license_spdx: str | None = Query(None), + is_direct: bool | None = Query(None), + is_dev: bool | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[SBOMEntryRead]: + q = select(SBOMEntry).order_by(SBOMEntry.package_name) + if repo_slug: + repo = await _get_repo_by_slug(repo_slug, session) + q = q.where(SBOMEntry.repo_id == repo.id) + if ecosystem is not None: + q = q.where(SBOMEntry.ecosystem == ecosystem) + if license_spdx: + q = q.where(SBOMEntry.license_spdx == license_spdx) + if is_direct is not None: + q = q.where(SBOMEntry.is_direct == is_direct) + if is_dev is not None: + q = q.where(SBOMEntry.is_dev == is_dev) + result = await session.execute(q) + return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()] + + +@router.get("/report/licences/", response_model=LicenceReport) +async def licence_report( + session: AsyncSession = Depends(get_session), +) -> LicenceReport: + """Group SBOM entries by SPDX licence identifier, flag copyleft.""" + rows = await session.execute( + select(SBOMEntry, ManagedRepo.slug) + .join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id) + ) + # Build: license_spdx → {count, repos set} + groups: dict[str | None, dict] = {} + copyleft_direct_count = 0 + for entry, repo_slug in rows.all(): + key = entry.license_spdx + if key not in groups: + groups[key] = {"count": 0, "repos": set()} + groups[key]["count"] += 1 + groups[key]["repos"].add(repo_slug) + if _is_copyleft(key) and entry.is_direct and not entry.is_dev: + copyleft_direct_count += 1 + + licence_groups = [ + LicenceGroup( + license_spdx=lic, + count=info["count"], + repos=sorted(info["repos"]), + is_copyleft=_is_copyleft(lic), + ) + for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"]) + ] + return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count) + + +@router.get("/{repo_slug}/", response_model=SBOMRepoView) +async def get_repo_sbom( + repo_slug: str, + session: AsyncSession = Depends(get_session), +) -> SBOMRepoView: + repo = await _get_repo_by_slug(repo_slug, session) + rows = await session.execute( + select(SBOMEntry).where(SBOMEntry.repo_id == repo.id).order_by(SBOMEntry.package_name) + ) + entries = list(rows.scalars().all()) + return SBOMRepoView( + repo_slug=repo_slug, + last_sbom_at=repo.last_sbom_at, + entry_count=len(entries), + entries=[SBOMEntryRead.model_validate(e) for e in entries], + ) + + +async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo: + result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug)) + repo = result.scalar_one_or_none() + if repo is None: + raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found") + return repo diff --git a/api/routers/state.py b/api/routers/state.py index 215305b..0932650 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -6,11 +6,13 @@ from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine +from api.models.contribution import Contribution, ContributionStatus, ContributionType from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.domain import Domain from api.models.extension_point import ExtensionPoint from api.models.managed_repo import ManagedRepo from api.models.progress_event import ProgressEvent +from api.models.sbom_entry import SBOMEntry from api.models.task import Task, TaskPriority, TaskStatus from api.models.technical_debt import TechnicalDebt from api.models.topic import Topic, TopicStatus @@ -175,6 +177,33 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm # Domain summary stats domain_summaries = await _build_domain_summaries(session) + # Contribution counts (by type and status) + contrib_type_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.type, func.count()).group_by(Contribution.type) + )} + contrib_status_counts = {r[0].value: r[1] for r in await session.execute( + select(Contribution.status, func.count()).group_by(Contribution.status) + )} + contribution_counts = {**contrib_type_counts, **contrib_status_counts} + + # Licence risk: copyleft packages in direct prod deps + _COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL") + copyleft_risk_rows = await session.execute( + select(func.count()).select_from(SBOMEntry) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + # Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy + all_direct_prod_rows = await session.execute( + select(SBOMEntry.license_spdx) + .where(SBOMEntry.is_direct.is_(True)) + .where(SBOMEntry.is_dev.is_(False)) + ) + licence_risk_count = sum( + 1 for (lic,) in all_direct_prod_rows.all() + if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) + ) + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -184,6 +213,8 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm recent_progress=[ProgressEventRead.model_validate(e) for e in recent], next_steps=next_steps, domains=domain_summaries, + contribution_counts=contribution_counts, + licence_risk_count=licence_risk_count, open_workstreams=[ WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), diff --git a/api/schemas/contribution.py b/api/schemas/contribution.py new file mode 100644 index 0000000..e037147 --- /dev/null +++ b/api/schemas/contribution.py @@ -0,0 +1,43 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.contribution import ContributionStatus, ContributionType + + +class ContributionCreate(BaseModel): + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workstream_id: uuid.UUID | None = None + notes: str | None = None + + +class ContributionStatusPatch(BaseModel): + status: ContributionStatus + notes: str | None = None + + +class ContributionRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + type: ContributionType + target_org: str | None = None + target_repo: str | None = None + slug: str | None = None + title: str + status: ContributionStatus + body_path: str | None = None + related_topic_id: uuid.UUID | None = None + related_workstream_id: uuid.UUID | None = None + submitted_at: datetime | None = None + resolved_at: datetime | None = None + notes: str | None = None + created_at: datetime + updated_at: datetime diff --git a/api/schemas/sbom.py b/api/schemas/sbom.py new file mode 100644 index 0000000..b2b3670 --- /dev/null +++ b/api/schemas/sbom.py @@ -0,0 +1,54 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from api.models.sbom_entry import Ecosystem + + +class SBOMEntryCreate(BaseModel): + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool = True + is_dev: bool = False + + +class SBOMIngest(BaseModel): + repo_slug: str + entries: list[SBOMEntryCreate] + + +class SBOMEntryRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + repo_id: uuid.UUID + package_name: str + package_version: str | None = None + ecosystem: Ecosystem + license_spdx: str | None = None + is_direct: bool + is_dev: bool + snapshot_at: datetime + created_at: datetime + + +class LicenceGroup(BaseModel): + license_spdx: str | None + count: int + repos: list[str] + is_copyleft: bool + + +class LicenceReport(BaseModel): + groups: list[LicenceGroup] + copyleft_direct_count: int + + +class SBOMRepoView(BaseModel): + repo_slug: str + last_sbom_at: datetime | None = None + entry_count: int + entries: list[SBOMEntryRead] diff --git a/api/schemas/state.py b/api/schemas/state.py index a695f72..6d49e6d 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -77,3 +77,5 @@ class StateSummary(BaseModel): open_workstreams: list[WorkstreamWithDeps] next_steps: list[NextStep] = [] domains: list[DomainSummary] = [] + contribution_counts: dict[str, int] = {} + licence_risk_count: int = 0 diff --git a/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py b/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py new file mode 100644 index 0000000..4429346 --- /dev/null +++ b/migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py @@ -0,0 +1,66 @@ +"""v0.3 — contributions table + +Adds contribution tracking: bug reports, feature requests, extension-point +proposals, and upstream PRs, each as a typed artifact with status lifecycle. + +Revision ID: c2d3e4f5a6b7 +Revises: b1c2d3e4f5a6 +Create Date: 2026-02-28 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "c2d3e4f5a6b7" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + contributiontype = postgresql.ENUM( + "br", "fr", "ep", "upr", + name="contributiontype", create_type=True, + ) + contributionstatus = postgresql.ENUM( + "draft", "submitted", "acknowledged", "accepted", + "rejected", "merged", "withdrawn", + name="contributionstatus", create_type=True, + ) + + op.create_table( + "contributions", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("type", contributiontype, nullable=False), + sa.Column("target_org", sa.String(200), nullable=True), + sa.Column("target_repo", sa.String(200), nullable=True), + sa.Column("slug", sa.String(200), nullable=True), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("status", contributionstatus, nullable=False, + server_default="draft"), + sa.Column("body_path", sa.Text, nullable=True), + sa.Column("related_topic_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("topics.id", ondelete="SET NULL"), nullable=True), + sa.Column("related_workstream_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("notes", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_contributions_type", "contributions", ["type"]) + op.create_index("ix_contributions_status", "contributions", ["status"]) + op.create_index("ix_contributions_target_repo", "contributions", + ["target_org", "target_repo"]) + + +def downgrade() -> None: + op.drop_table("contributions") + op.execute("DROP TYPE IF EXISTS contributionstatus") + op.execute("DROP TYPE IF EXISTS contributiontype") diff --git a/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py b/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py new file mode 100644 index 0000000..1dc21ce --- /dev/null +++ b/migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py @@ -0,0 +1,60 @@ +"""v0.3 — sbom_entries table + managed_repos SBOM columns + +Adds software bill-of-materials tracking: per-repo dependency snapshots with +package name, version, ecosystem, SPDX licence, and direct/dev flags. +Also adds sbom_source and last_sbom_at to managed_repos. + +Revision ID: d3e4f5a6b7c8 +Revises: c2d3e4f5a6b7 +Create Date: 2026-02-28 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c2d3e4f5a6b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add SBOM-related columns to managed_repos + op.add_column("managed_repos", sa.Column("sbom_source", sa.Text, nullable=True)) + op.add_column("managed_repos", + sa.Column("last_sbom_at", sa.DateTime(timezone=True), nullable=True)) + + ecosystem_enum = postgresql.ENUM( + "python", "node", "rust", "go", "java", "other", + name="ecosystem", create_type=True, + ) + + op.create_table( + "sbom_entries", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("repo_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False), + sa.Column("package_name", sa.String(300), nullable=False), + sa.Column("package_version", sa.String(100), nullable=True), + sa.Column("ecosystem", ecosystem_enum, nullable=False), + sa.Column("license_spdx", sa.String(100), nullable=True), + sa.Column("is_direct", sa.Boolean, nullable=False, server_default="true"), + sa.Column("is_dev", sa.Boolean, nullable=False, server_default="false"), + sa.Column("snapshot_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), + server_default=sa.text("now()"), nullable=False), + ) + op.create_index("ix_sbom_entries_repo_id", "sbom_entries", ["repo_id"]) + op.create_index("ix_sbom_entries_package_name", "sbom_entries", ["package_name"]) + op.create_index("ix_sbom_entries_license_spdx", "sbom_entries", ["license_spdx"]) + + +def downgrade() -> None: + op.drop_table("sbom_entries") + op.execute("DROP TYPE IF EXISTS ecosystem") + op.drop_column("managed_repos", "last_sbom_at") + op.drop_column("managed_repos", "sbom_source")