from __future__ import annotations import asyncio import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from fastapi.encoders import jsonable_encoder from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session from api.events import EventEnvelope, publish_event from api.models.repository_rename import RepositoryRenameOperation from api.schemas.repository_rename import ( ForgeIdentityVerifyRequest, RepositoryRenameOperationCreate, RepositoryRenameOperationRead, RepositoryRenamePhaseApply, RepositoryRenamePreflightRead, RepositoryRenamePreflightRequest, RepositoryRenameRollbackPreflightRead, RepositoryRenameRollbackRequest, RepositoryRenameVerificationRead, ) from api.services.forge_repository import ( ForgeRepositoryGateway, get_forge_repository_gateway, ) from api.services.repository_rename import ( RenameLifecycleError, apply_phase, apply_rollback, build_preflight, create_operation, list_operations, load_operation, load_operation_by_id, rollback_preflight, verify_forge_identity, verify_operation, ) router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"]) operation_router = APIRouter( prefix="/repository-renames", tags=["repository-renames"] ) def repository_rename_events( operation: RepositoryRenameOperation, *, outcome: str, no_op: bool = False, verification: dict[str, Any] | None = None, ) -> list[tuple[str, EventEnvelope]]: """Build credential-free lifecycle events from the durable journal.""" telemetry = (operation.evidence or {}).get("telemetry") or {} phase_durations = telemetry.get("phase_durations_ms") or {} attributes = { "operation_id": str(operation.id), "repo_id": str(operation.repo_id), "forge_repository_id": operation.expected_forge_repository_id, "old_slug": operation.old_slug, "new_slug": operation.new_slug, "phase": operation.phase, "actor": operation.actor, "expected_source_commit": operation.expected_source_commit, "outcome": outcome, "no_op": no_op, "phase_duration_ms": int(phase_durations.get(operation.phase) or 0), "retries": int(telemetry.get("retries") or 0), "failures": int(telemetry.get("failures") or 0), "rollback_attempts": int(telemetry.get("rollback_attempts") or 0), "rollback_outcome": telemetry.get("rollback_outcome"), "verification_outcome": ( "passed" if verification and verification.get("ok") else "failed" if verification else telemetry.get("verification_outcome", "pending") ), "error_code": operation.error_code if outcome == "failed" else None, "evidence_ref": f"repository-renames/operations/{operation.id}", } subject = ( "org.statehub.repo.rename.failed" if outcome == "failed" else "org.statehub.repo.rename.verified" if verification is not None else "org.statehub.repo.rename.rolled_back" if operation.phase == "rolled-back" else "org.statehub.repo.rename.phase" ) events = [(subject, EventEnvelope.new(subject, attributes=attributes))] if operation.phase == "completed" and outcome == "succeeded": renamed_subject = "org.statehub.repo.renamed" events.append( (renamed_subject, EventEnvelope.new(renamed_subject, attributes=attributes)) ) return events def _publish_rename_events( operation: RepositoryRenameOperation, *, outcome: str, no_op: bool = False, verification: dict[str, Any] | None = None, ) -> None: for subject, envelope in repository_rename_events( operation, outcome=outcome, no_op=no_op, verification=verification ): asyncio.create_task(publish_event(subject, envelope)) def _raise(exc: RenameLifecycleError) -> None: raise HTTPException( status_code=exc.status_code, detail=jsonable_encoder( {"code": exc.code, "message": str(exc), "details": exc.details} ), ) from exc def _operation_read(operation: RepositoryRenameOperation, *, no_op: bool = False) -> dict[str, Any]: return { "id": operation.id, "repo_id": operation.repo_id, "phase": operation.phase, "old_slug": operation.old_slug, "new_slug": operation.new_slug, "expected_forge_repository_id": operation.expected_forge_repository_id, "expected_source_commit": operation.expected_source_commit, "expected_default_branch": operation.expected_default_branch, "actor": operation.actor, "phase_changed_at": operation.phase_changed_at, "preflighted_at": operation.preflighted_at, "preflight_expires_at": operation.preflight_expires_at, "completed_at": operation.completed_at, "rolled_back_at": operation.rolled_back_at, "evidence": operation.evidence, "error_code": operation.error_code, "error_message": operation.error_message, "error_details": operation.error_details, "error_at": operation.error_at, "no_op": no_op, } @router.post("/forge-identity/verify") async def verify_repository_forge_identity( repo_id: uuid.UUID, body: ForgeIdentityVerifyRequest, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: identity = await verify_forge_identity(session, gateway, repo_id, body) except RenameLifecycleError as exc: _raise(exc) return { "id": identity.id, "repo_id": identity.repo_id, "provider": identity.provider, "forge_instance": identity.forge_instance, "forge_owner": identity.forge_owner, "forge_repository_id": identity.forge_repository_id, "verification_state": identity.verification_state, "verified_at": identity.verified_at, "verified_by": identity.verified_by, "verification_evidence": identity.verification_evidence, } @router.post("/rename/preflight", response_model=RepositoryRenamePreflightRead) async def repository_rename_preflight( repo_id: uuid.UUID, body: RepositoryRenamePreflightRequest, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: return await build_preflight(session, gateway, repo_id, body) except RenameLifecycleError as exc: _raise(exc) @router.post( "/rename/operations", response_model=RepositoryRenameOperationRead, status_code=status.HTTP_201_CREATED, ) async def create_repository_rename_operation( repo_id: uuid.UUID, body: RepositoryRenameOperationCreate, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: operation, no_op = await create_operation(session, gateway, repo_id, body) except RenameLifecycleError as exc: _raise(exc) _publish_rename_events(operation, outcome="succeeded", no_op=no_op) return _operation_read(operation, no_op=no_op) @operation_router.get( "/operations/{operation_id}", response_model=RepositoryRenameOperationRead ) async def get_repository_rename_operation_by_id( operation_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: """Resolve an operation journal without requiring its repository UUID.""" try: operation = await load_operation_by_id(session, operation_id) except RenameLifecycleError as exc: _raise(exc) return _operation_read(operation) @router.get( "/rename/operations", response_model=list[RepositoryRenameOperationRead] ) async def list_repository_rename_operations( repo_id: uuid.UUID, active_only: bool = False, session: AsyncSession = Depends(get_session), ) -> list[dict[str, Any]]: try: operations = await list_operations(session, repo_id, active_only=active_only) except RenameLifecycleError as exc: _raise(exc) return [_operation_read(operation) for operation in operations] @router.get( "/rename/operations/{operation_id}", response_model=RepositoryRenameOperationRead ) async def get_repository_rename_operation( repo_id: uuid.UUID, operation_id: uuid.UUID, session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: try: operation = await load_operation(session, repo_id, operation_id) except RenameLifecycleError as exc: _raise(exc) return _operation_read(operation) @router.post( "/rename/operations/{operation_id}/phases/{phase}", response_model=RepositoryRenameOperationRead, ) async def apply_repository_rename_phase( repo_id: uuid.UUID, operation_id: uuid.UUID, phase: str, body: RepositoryRenamePhaseApply, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: operation, no_op = await apply_phase( session, gateway, repo_id, operation_id, phase, body ) except RenameLifecycleError as exc: failed = await session.get(RepositoryRenameOperation, operation_id) if failed is not None: _publish_rename_events(failed, outcome="failed") _raise(exc) _publish_rename_events(operation, outcome="succeeded", no_op=no_op) return _operation_read(operation, no_op=no_op) @router.get( "/rename/operations/{operation_id}/verify", response_model=RepositoryRenameVerificationRead, ) async def verify_repository_rename_operation( repo_id: uuid.UUID, operation_id: uuid.UUID, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: operation = await load_operation(session, repo_id, operation_id) verification = await verify_operation(session, gateway, operation) except RenameLifecycleError as exc: _raise(exc) _publish_rename_events( operation, outcome="succeeded" if verification["ok"] else "failed", verification=verification, ) return verification @router.post( "/rename/operations/{operation_id}/rollback-preflight", response_model=RepositoryRenameRollbackPreflightRead, ) async def preflight_repository_rename_rollback( repo_id: uuid.UUID, operation_id: uuid.UUID, body: RepositoryRenameRollbackRequest, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: operation, report = await rollback_preflight( session, gateway, repo_id, operation_id, expected_phase=body.expected_phase, confirmation=body.confirmation, ) except RenameLifecycleError as exc: failed = await session.get(RepositoryRenameOperation, operation_id) if failed is not None: _publish_rename_events(failed, outcome="failed") _raise(exc) _publish_rename_events(operation, outcome="succeeded") return { "operation_id": operation.id, "repo_id": operation.repo_id, **report, "operation": _operation_read(operation), } @router.post( "/rename/operations/{operation_id}/rollback", response_model=RepositoryRenameOperationRead, ) async def rollback_repository_rename_operation( repo_id: uuid.UUID, operation_id: uuid.UUID, body: RepositoryRenameRollbackRequest, session: AsyncSession = Depends(get_session), gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway), ) -> dict[str, Any]: try: operation, no_op = await apply_rollback( session, gateway, repo_id, operation_id, expected_phase=body.expected_phase, confirmation=body.confirmation, ) except RenameLifecycleError as exc: failed = await session.get(RepositoryRenameOperation, operation_id) if failed is not None: _publish_rename_events(failed, outcome="failed") _raise(exc) _publish_rename_events(operation, outcome="succeeded", no_op=no_op) return _operation_read(operation, no_op=no_op)