state-hub/api/routers/interface_changes.py

193 lines
7.3 KiB
Python
Raw Normal View History

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.agent_message import AgentMessage
from api.models.interface_change import InterfaceChange
from api.models.managed_repo import ManagedRepo
from api.models.progress_event import ProgressEvent
from api.schemas.interface_change import (
InterfaceChangeCreate,
InterfaceChangePatch,
InterfaceChangeRead,
)
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
async def create_interface_change(
body: InterfaceChangeCreate,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
if body.interface_type not in _VALID_INTERFACE_TYPES:
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}")
if body.change_type not in _VALID_CHANGE_TYPES:
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
repo = await _repo_by_slug(body.repo_slug, session)
change = InterfaceChange(
repo_id=repo.id,
interface_type=body.interface_type,
change_type=body.change_type,
title=body.title,
description=body.description,
affected_paths=body.affected_paths,
affected_repo_slugs=body.affected_repo_slugs,
planned_for=body.planned_for,
author=body.author,
status="draft",
)
session.add(change)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.get("/", response_model=list[InterfaceChangeRead])
async def list_interface_changes(
repo_slug: str | None = Query(None),
status: str | None = Query(None),
change_type: str | None = Query(None),
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
session: AsyncSession = Depends(get_session),
) -> list[InterfaceChangeRead]:
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
if repo_slug:
repo = await _repo_by_slug(repo_slug, session)
q = q.where(InterfaceChange.repo_id == repo.id)
if status:
q = q.where(InterfaceChange.status == status)
if change_type:
q = q.where(InterfaceChange.change_type == change_type)
if affected_repo:
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
result = await session.execute(q)
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
@router.get("/{change_id}", response_model=InterfaceChangeRead)
async def get_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
async def patch_interface_change(
change_id: uuid.UUID,
body: InterfaceChangePatch,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "draft":
raise HTTPException(
status_code=409,
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(change, field, value)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
async def publish_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "draft":
raise HTTPException(
status_code=409,
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
)
now = datetime.now(tz=timezone.utc)
change.status = "published"
change.published_at = now
# Send inbox notifications to agents of affected repos
affected = change.affected_repo_slugs or []
for slug in affected:
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
if len(change.affected_paths) > 5:
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
msg = AgentMessage(
from_agent=change.repo.slug,
to_agent=slug,
subject=f"[{change.change_type.upper()}] {change.title}",
body=(
f"**Interface change published by `{change.repo.slug}`**\n\n"
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
f"- Affected paths: {paths_summary}\n\n"
f"{change.description}\n\n"
f"Change ID: `{change.id}` — resolve with "
f"`POST /interface-changes/{change.id}/resolve` once adapted."
),
)
session.add(msg)
# Progress event on the originating repo
session.add(ProgressEvent(
event_type="milestone",
summary=f"Interface change published: {change.title}",
detail={
"change_id": str(change.id),
"change_type": change.change_type,
"interface_type": change.interface_type,
"affected_repos": affected,
"notifications_sent": len(affected),
},
author=change.author,
))
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
async def resolve_interface_change(
change_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> InterfaceChangeRead:
change = await _get_or_404(change_id, session)
if change.status != "published":
raise HTTPException(
status_code=409,
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
)
change.status = "resolved"
change.resolved_at = datetime.now(tz=timezone.utc)
await session.commit()
await session.refresh(change)
return InterfaceChangeRead.from_orm_with_slug(change)
async def _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
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
result = await session.execute(
select(InterfaceChange).where(InterfaceChange.id == change_id)
)
change = result.scalar_one_or_none()
if change is None:
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
return change