generated from coulomb/repo-seed
feat(state-hub): v0.3 schema — contributions + sbom_entries migrations, models, schemas, routers
Migrations (chain: b1c2d3e4f5a6 → c2d3e4f5a6b7 → d3e4f5a6b7c8): - c2d3e4f5a6b7: contributions table (contributiontype BR/FR/EP/UPR enum, contributionstatus 7-state lifecycle, FKs to topics/workstreams) - d3e4f5a6b7c8: sbom_entries table (ecosystem enum, snapshot-based replacement), + sbom_source + last_sbom_at columns on managed_repos New models: Contribution (ContributionType, ContributionStatus), SBOMEntry (Ecosystem) Modified: ManagedRepo (sbom_source, last_sbom_at columns) New routers: - /contributions/ — CRUD + lifecycle-guarded PATCH /status + soft-delete (withdrawn) - /sbom/ — ingest (replace snapshot), list, per-repo view, licence report Modified: - /state/summary now includes contribution_counts and licence_risk_count - main.py: registers contributions + sbom routers; bumps version to 0.6.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from api.database import engine
|
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 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
|
@asynccontextmanager
|
||||||
@@ -17,7 +17,7 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Custodian State Hub",
|
title="Custodian State Hub",
|
||||||
description="Local-first state API for the Custodian agent system.",
|
description="Local-first state API for the Custodian agent system.",
|
||||||
version="0.5.0",
|
version="0.6.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ app.include_router(decisions.router)
|
|||||||
app.include_router(extension_points.router)
|
app.include_router(extension_points.router)
|
||||||
app.include_router(technical_debt.router)
|
app.include_router(technical_debt.router)
|
||||||
app.include_router(progress.router)
|
app.include_router(progress.router)
|
||||||
|
app.include_router(contributions.router)
|
||||||
|
app.include_router(sbom.router)
|
||||||
app.include_router(state.router)
|
app.include_router(state.router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from api.models.progress_event import ProgressEvent
|
|||||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
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.sbom_entry import SBOMEntry, Ecosystem
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -22,4 +24,6 @@ __all__ = [
|
|||||||
"ExtensionPoint", "EPStatus",
|
"ExtensionPoint", "EPStatus",
|
||||||
"TechnicalDebt", "TDStatus",
|
"TechnicalDebt", "TDStatus",
|
||||||
"ManagedRepo",
|
"ManagedRepo",
|
||||||
|
"Contribution", "ContributionType", "ContributionStatus",
|
||||||
|
"SBOMEntry", "Ecosystem",
|
||||||
]
|
]
|
||||||
|
|||||||
62
api/models/contribution.py
Normal file
62
api/models/contribution.py
Normal file
@@ -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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import uuid
|
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.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ class ManagedRepo(Base, TimestampMixin):
|
|||||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
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: Mapped["Domain"] = relationship( # noqa: F821
|
||||||
"Domain", back_populates="repos", lazy="selectin"
|
"Domain", back_populates="repos", lazy="selectin"
|
||||||
|
|||||||
47
api/models/sbom_entry.py
Normal file
47
api/models/sbom_entry.py
Normal file
@@ -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
|
||||||
147
api/routers/contributions.py
Normal file
147
api/routers/contributions.py
Normal file
@@ -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
|
||||||
146
api/routers/sbom.py
Normal file
146
api/routers/sbom.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.database import get_session
|
||||||
|
from api.models.managed_repo import ManagedRepo
|
||||||
|
from api.models.sbom_entry import Ecosystem, SBOMEntry
|
||||||
|
from api.schemas.sbom import (
|
||||||
|
LicenceGroup,
|
||||||
|
LicenceReport,
|
||||||
|
SBOMEntryRead,
|
||||||
|
SBOMIngest,
|
||||||
|
SBOMRepoView,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sbom", tags=["sbom"])
|
||||||
|
|
||||||
|
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_copyleft(spdx: str | None) -> bool:
|
||||||
|
if not spdx:
|
||||||
|
return False
|
||||||
|
upper = spdx.upper()
|
||||||
|
return any(pat in upper for pat in _COPYLEFT_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest/")
|
||||||
|
async def ingest_sbom(
|
||||||
|
body: SBOMIngest,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Replace the SBOM snapshot for a repo. Old entries are deleted first."""
|
||||||
|
repo = await _get_repo_by_slug(body.repo_slug, session)
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
|
||||||
|
# Delete existing snapshot for this repo
|
||||||
|
await session.execute(delete(SBOMEntry).where(SBOMEntry.repo_id == repo.id))
|
||||||
|
|
||||||
|
# Insert new entries
|
||||||
|
for entry in body.entries:
|
||||||
|
sbom = SBOMEntry(
|
||||||
|
repo_id=repo.id,
|
||||||
|
package_name=entry.package_name,
|
||||||
|
package_version=entry.package_version,
|
||||||
|
ecosystem=entry.ecosystem,
|
||||||
|
license_spdx=entry.license_spdx,
|
||||||
|
is_direct=entry.is_direct,
|
||||||
|
is_dev=entry.is_dev,
|
||||||
|
snapshot_at=now,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
session.add(sbom)
|
||||||
|
|
||||||
|
repo.last_sbom_at = now
|
||||||
|
if not repo.sbom_source:
|
||||||
|
repo.sbom_source = "manual"
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return {"repo_slug": body.repo_slug, "ingested": len(body.entries), "snapshot_at": now.isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_sbom_entries(
|
||||||
|
repo_slug: str | None = Query(None),
|
||||||
|
ecosystem: Ecosystem | None = Query(None),
|
||||||
|
license_spdx: str | None = Query(None),
|
||||||
|
is_direct: bool | None = Query(None),
|
||||||
|
is_dev: bool | None = Query(None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[SBOMEntryRead]:
|
||||||
|
q = select(SBOMEntry).order_by(SBOMEntry.package_name)
|
||||||
|
if repo_slug:
|
||||||
|
repo = await _get_repo_by_slug(repo_slug, session)
|
||||||
|
q = q.where(SBOMEntry.repo_id == repo.id)
|
||||||
|
if ecosystem is not None:
|
||||||
|
q = q.where(SBOMEntry.ecosystem == ecosystem)
|
||||||
|
if license_spdx:
|
||||||
|
q = q.where(SBOMEntry.license_spdx == license_spdx)
|
||||||
|
if is_direct is not None:
|
||||||
|
q = q.where(SBOMEntry.is_direct == is_direct)
|
||||||
|
if is_dev is not None:
|
||||||
|
q = q.where(SBOMEntry.is_dev == is_dev)
|
||||||
|
result = await session.execute(q)
|
||||||
|
return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/licences/", response_model=LicenceReport)
|
||||||
|
async def licence_report(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> LicenceReport:
|
||||||
|
"""Group SBOM entries by SPDX licence identifier, flag copyleft."""
|
||||||
|
rows = await session.execute(
|
||||||
|
select(SBOMEntry, ManagedRepo.slug)
|
||||||
|
.join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id)
|
||||||
|
)
|
||||||
|
# Build: license_spdx → {count, repos set}
|
||||||
|
groups: dict[str | None, dict] = {}
|
||||||
|
copyleft_direct_count = 0
|
||||||
|
for entry, repo_slug in rows.all():
|
||||||
|
key = entry.license_spdx
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = {"count": 0, "repos": set()}
|
||||||
|
groups[key]["count"] += 1
|
||||||
|
groups[key]["repos"].add(repo_slug)
|
||||||
|
if _is_copyleft(key) and entry.is_direct and not entry.is_dev:
|
||||||
|
copyleft_direct_count += 1
|
||||||
|
|
||||||
|
licence_groups = [
|
||||||
|
LicenceGroup(
|
||||||
|
license_spdx=lic,
|
||||||
|
count=info["count"],
|
||||||
|
repos=sorted(info["repos"]),
|
||||||
|
is_copyleft=_is_copyleft(lic),
|
||||||
|
)
|
||||||
|
for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"])
|
||||||
|
]
|
||||||
|
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{repo_slug}/", response_model=SBOMRepoView)
|
||||||
|
async def get_repo_sbom(
|
||||||
|
repo_slug: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SBOMRepoView:
|
||||||
|
repo = await _get_repo_by_slug(repo_slug, session)
|
||||||
|
rows = await session.execute(
|
||||||
|
select(SBOMEntry).where(SBOMEntry.repo_id == repo.id).order_by(SBOMEntry.package_name)
|
||||||
|
)
|
||||||
|
entries = list(rows.scalars().all())
|
||||||
|
return SBOMRepoView(
|
||||||
|
repo_slug=repo_slug,
|
||||||
|
last_sbom_at=repo.last_sbom_at,
|
||||||
|
entry_count=len(entries),
|
||||||
|
entries=[SBOMEntryRead.model_validate(e) for e in entries],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||||
|
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||||
|
repo = result.scalar_one_or_none()
|
||||||
|
if repo is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||||
|
return repo
|
||||||
@@ -6,11 +6,13 @@ from sqlalchemy import func, select, text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from api.database import get_session, engine
|
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.decision import Decision, DecisionStatus, DecisionType
|
||||||
from api.models.domain import Domain
|
from api.models.domain import Domain
|
||||||
from api.models.extension_point import ExtensionPoint
|
from api.models.extension_point import ExtensionPoint
|
||||||
from api.models.managed_repo import ManagedRepo
|
from api.models.managed_repo import ManagedRepo
|
||||||
from api.models.progress_event import ProgressEvent
|
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.task import Task, TaskPriority, TaskStatus
|
||||||
from api.models.technical_debt import TechnicalDebt
|
from api.models.technical_debt import TechnicalDebt
|
||||||
from api.models.topic import Topic, TopicStatus
|
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 summary stats
|
||||||
domain_summaries = await _build_domain_summaries(session)
|
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(
|
return StateSummary(
|
||||||
generated_at=datetime.now(tz=timezone.utc),
|
generated_at=datetime.now(tz=timezone.utc),
|
||||||
totals=totals,
|
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],
|
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||||
next_steps=next_steps,
|
next_steps=next_steps,
|
||||||
domains=domain_summaries,
|
domains=domain_summaries,
|
||||||
|
contribution_counts=contribution_counts,
|
||||||
|
licence_risk_count=licence_risk_count,
|
||||||
open_workstreams=[
|
open_workstreams=[
|
||||||
WorkstreamWithDeps(
|
WorkstreamWithDeps(
|
||||||
**WorkstreamRead.model_validate(w).model_dump(),
|
**WorkstreamRead.model_validate(w).model_dump(),
|
||||||
|
|||||||
43
api/schemas/contribution.py
Normal file
43
api/schemas/contribution.py
Normal file
@@ -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
|
||||||
54
api/schemas/sbom.py
Normal file
54
api/schemas/sbom.py
Normal file
@@ -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]
|
||||||
@@ -77,3 +77,5 @@ class StateSummary(BaseModel):
|
|||||||
open_workstreams: list[WorkstreamWithDeps]
|
open_workstreams: list[WorkstreamWithDeps]
|
||||||
next_steps: list[NextStep] = []
|
next_steps: list[NextStep] = []
|
||||||
domains: list[DomainSummary] = []
|
domains: list[DomainSummary] = []
|
||||||
|
contribution_counts: dict[str, int] = {}
|
||||||
|
licence_risk_count: int = 0
|
||||||
|
|||||||
66
migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py
Normal file
66
migrations/versions/c2d3e4f5a6b7_v0_3_contributions.py
Normal file
@@ -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")
|
||||||
60
migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py
Normal file
60
migrations/versions/d3e4f5a6b7c8_v0_3_sbom_entries.py
Normal file
@@ -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")
|
||||||
Reference in New Issue
Block a user