Files
the-custodian/state-hub/api/routers/contributions.py
tegwick 8dd15efde1 fix(api): normalize trailing slashes — no slash on param routes
Rule: trailing slash only on collection roots (/). Any route containing
a path parameter {…} uses no trailing slash. Applies across all routers,
scripts, Makefile, and tests. Fixes 307-redirect fragility on POST/PATCH
from naive clients (curl, Codex HTTP calls).

Also adds POST /repos/{slug}/sync — runs ADR-001 consistency check with
--fix via HTTP, so non-MCP agents (Codex) can self-service DB sync without
operator intervention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:13:01 +02: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