state-hub/api/routers/repository_renames.py
tegwick 9f0a104b56
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s
feat: prove repository rename continuity
Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
2026-08-29 15:05:09 +02:00

359 lines
12 KiB
Python

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)