feat: add repository rename orchestration CLI
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 25s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
tegwick 2026-08-29 12:00:22 +02:00
parent 6312db8700
commit 6b82215ae7
11 changed files with 1373 additions and 4 deletions

View file

@ -111,6 +111,7 @@ app.include_router(recently_on_scope.router)
app.include_router(consistency_sweep.router)
app.include_router(repos.router)
app.include_router(repository_renames.router)
app.include_router(repository_renames.operation_router)
app.include_router(topics.router)
app.include_router(workstreams.router)
app.include_router(workstreams.workplan_router)

View file

@ -31,6 +31,7 @@ from api.services.repository_rename import (
create_operation,
list_operations,
load_operation,
load_operation_by_id,
rollback_preflight,
verify_forge_identity,
verify_operation,
@ -38,6 +39,9 @@ from api.services.repository_rename import (
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
operation_router = APIRouter(
prefix="/repository-renames", tags=["repository-renames"]
)
def _raise(exc: RenameLifecycleError) -> None:
@ -122,7 +126,22 @@ async def create_repository_rename_operation(
gateway: ForgeRepositoryGateway = Depends(get_forge_repository_gateway),
) -> dict[str, Any]:
try:
operation = await create_operation(session, gateway, repo_id, body)
operation, no_op = await create_operation(session, gateway, repo_id, body)
except RenameLifecycleError as exc:
_raise(exc)
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)

View file

@ -50,6 +50,7 @@ class ForgeIdentityVerifyRequest(BaseModel):
class RepositoryRenameOperationCreate(BaseModel):
operation_id: uuid.UUID | None = None
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
preflight_token: str
confirmation: str

View file

@ -695,7 +695,22 @@ async def create_operation(
gateway: ForgeRepositoryGateway,
repo_id: uuid.UUID,
body: RepositoryRenameOperationCreate,
) -> RepositoryRenameOperation:
) -> tuple[RepositoryRenameOperation, bool]:
if body.operation_id is not None:
existing = await session.get(RepositoryRenameOperation, body.operation_id)
if existing is not None:
if (
existing.repo_id != repo_id
or existing.new_slug != body.new_slug
or existing.actor != body.actor
or body.confirmation
!= confirmation_for(repo_id, existing.old_slug, existing.new_slug)
):
raise RenamePreconditionFailed(
"Repository rename operation ID is already bound to another request"
)
return existing, True
token = _verify_preflight_token(body.preflight_token)
if token.get("repo_id") != str(repo_id) or token.get("new_slug") != body.new_slug:
raise RenamePreconditionFailed("Preflight token does not address this rename")
@ -730,6 +745,7 @@ async def create_operation(
forge = preflight["current"]["forge"]
now = utcnow()
operation = RepositoryRenameOperation(
id=body.operation_id or uuid.uuid4(),
repo_id=repo.id,
forge_identity_id=identity.id,
forge_identity_state="verified",
@ -777,10 +793,33 @@ async def create_operation(
await session.commit()
except IntegrityError as exc:
await session.rollback()
if body.operation_id is not None:
existing = await session.get(RepositoryRenameOperation, body.operation_id)
if (
existing is not None
and existing.repo_id == repo_id
and existing.new_slug == body.new_slug
and existing.actor == body.actor
and body.confirmation
== confirmation_for(repo_id, existing.old_slug, existing.new_slug)
):
return existing, True
raise RenamePreconditionFailed(
"A conflicting repository rename operation was created"
) from exc
await session.refresh(operation)
return operation, False
async def load_operation_by_id(
session: AsyncSession,
operation_id: uuid.UUID,
) -> RepositoryRenameOperation:
operation = await session.get(RepositoryRenameOperation, operation_id)
if operation is None:
raise RenameNotFound(
f"Repository rename operation {operation_id} was not found"
)
return operation