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