Files
state-hub/api/routers/contributions.py
tegwick 8d38110275 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>
2026-02-28 17:28:27 +01:00

148 lines
5.1 KiB
Python

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