Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
359 lines
12 KiB
Python
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)
|