193 lines
7.3 KiB
Python
193 lines
7.3 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.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
|