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:
2026-02-28 17:28:27 +01:00
parent 6edd39f4b8
commit 8d38110275
13 changed files with 672 additions and 3 deletions

View File

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

View File

@@ -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",
] ]

View 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

View File

@@ -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
View 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

View 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
View 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

View File

@@ -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(),

View 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
View 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]

View File

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

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

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