feat: prove repository rename continuity
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

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 15:05:09 +02:00
parent 037c8360e2
commit 9f0a104b56
11 changed files with 1186 additions and 96 deletions

View file

@ -1,12 +1,15 @@
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,
@ -44,10 +47,77 @@ operation_router = APIRouter(
)
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={"code": exc.code, "message": str(exc), "details": exc.details},
detail=jsonable_encoder(
{"code": exc.code, "message": str(exc), "details": exc.details}
),
) from exc
@ -129,6 +199,7 @@ async def create_repository_rename_operation(
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)
@ -194,7 +265,11 @@ async def apply_repository_rename_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)
@ -210,9 +285,15 @@ async def verify_repository_rename_operation(
) -> dict[str, Any]:
try:
operation = await load_operation(session, repo_id, operation_id)
return await verify_operation(session, gateway, operation)
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(
@ -236,7 +317,11 @@ async def preflight_repository_rename_rollback(
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,
@ -266,5 +351,9 @@ async def rollback_repository_rename_operation(
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)