Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
209 lines
7.9 KiB
Python
209 lines
7.9 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.progress_event import ProgressEvent
|
|
from api.schemas.interface_change import (
|
|
InterfaceChangeCreate,
|
|
InterfaceChangePatch,
|
|
InterfaceChangeRead,
|
|
)
|
|
from api.services.repository_aliases import (
|
|
affected_slug_predicate,
|
|
canonicalize_repository_slug,
|
|
resolve_repository_slug,
|
|
)
|
|
|
|
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)}")
|
|
|
|
resolution = await resolve_repository_slug(session, body.repo_slug)
|
|
repo = resolution.repo
|
|
affected_repo_slugs = [
|
|
await canonicalize_repository_slug(session, slug)
|
|
for slug in body.affected_repo_slugs
|
|
]
|
|
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=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:
|
|
resolution = await resolve_repository_slug(
|
|
session, affected_repo, required=False
|
|
)
|
|
values = resolution.slug_values if resolution else (affected_repo,)
|
|
q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values))
|
|
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.",
|
|
)
|
|
payload = body.model_dump(exclude_unset=True)
|
|
if payload.get("affected_repo_slugs") is not None:
|
|
payload["affected_repo_slugs"] = [
|
|
await canonicalize_repository_slug(session, slug)
|
|
for slug in payload["affected_repo_slugs"]
|
|
]
|
|
for field, value in payload.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:
|
|
target_slug = await canonicalize_repository_slug(session, slug)
|
|
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=target_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):
|
|
resolution = await resolve_repository_slug(session, slug)
|
|
return resolution.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
|