feat: add repository rename lifecycle API
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a049a4-ee9f-78e1-9d66-2cb0f9bea3e3
This commit is contained in:
parent
9e77a4a9a2
commit
82ea38b180
11 changed files with 2464 additions and 3 deletions
|
|
@ -34,6 +34,14 @@ class Settings(BaseSettings):
|
|||
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_timeout_seconds: float = 5.0
|
||||
# Repository renames are central-only, compare-and-set operations. The
|
||||
# secret signs short-lived, non-persistent preflight evidence; it must be
|
||||
# supplied by the deployment (normally through OpenBao), never recorded in
|
||||
# State Hub. With no secret the read-only report remains available but no
|
||||
# mutation token can be issued.
|
||||
repository_rename_preflight_secret: str | None = None
|
||||
repository_rename_preflight_ttl_seconds: int = 900
|
||||
repository_rename_forge_timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from api.routers import fabric
|
|||
from api.routers import legacy_meter
|
||||
from api.routers import review_contracts
|
||||
from api.routers import identifier_migrations
|
||||
from api.routers import repository_renames
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
|
|
@ -109,6 +110,7 @@ app.include_router(recently_on_scope.hourly_router)
|
|||
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(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstreams.workplan_router)
|
||||
|
|
|
|||
251
api/routers/repository_renames.py
Normal file
251
api/routers/repository_renames.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
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,
|
||||
rollback_preflight,
|
||||
verify_forge_identity,
|
||||
verify_operation,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/repos/{repo_id}", tags=["repository-renames"])
|
||||
|
||||
|
||||
def _raise(exc: RenameLifecycleError) -> None:
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code,
|
||||
detail={"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 = await create_operation(session, gateway, repo_id, body)
|
||||
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:
|
||||
_raise(exc)
|
||||
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)
|
||||
return await verify_operation(session, gateway, operation)
|
||||
except RenameLifecycleError as exc:
|
||||
_raise(exc)
|
||||
|
||||
|
||||
@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:
|
||||
_raise(exc)
|
||||
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:
|
||||
_raise(exc)
|
||||
return _operation_read(operation, no_op=no_op)
|
||||
112
api/schemas/repository_rename.py
Normal file
112
api/schemas/repository_rename.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EdgeWriteEvidence(BaseModel):
|
||||
id: str
|
||||
status: str = "queued"
|
||||
source_host: str | None = None
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRequest(BaseModel):
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePreflightRead(BaseModel):
|
||||
schema_version: Literal["state-hub.repository-rename-preflight.v1"]
|
||||
repo_id: uuid.UUID
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
safe_to_apply: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
warnings: list[dict[str, Any]]
|
||||
current: dict[str, Any]
|
||||
target: dict[str, Any]
|
||||
baselines: dict[str, Any]
|
||||
active_work: dict[str, Any]
|
||||
affected: dict[str, Any]
|
||||
queued_edge_writes: list[dict[str, Any]]
|
||||
proposed_mutations: list[dict[str, Any]]
|
||||
retained_history: list[dict[str, Any]]
|
||||
report_checksum: str
|
||||
preflight_token: str | None
|
||||
preflighted_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
class ForgeIdentityVerifyRequest(BaseModel):
|
||||
provider: Literal["forgejo"] = "forgejo"
|
||||
forge_instance: str
|
||||
forge_owner: str
|
||||
forge_repository_id: int = Field(gt=0)
|
||||
verified_by: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class RepositoryRenameOperationCreate(BaseModel):
|
||||
new_slug: str = Field(min_length=1, max_length=100, pattern=r"^[a-z0-9][a-z0-9-]*$")
|
||||
preflight_token: str
|
||||
confirmation: str
|
||||
actor: str = Field(min_length=1, max_length=160)
|
||||
queued_edge_writes: list[EdgeWriteEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepositoryRenamePhaseApply(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
checks: dict[str, bool] = Field(default_factory=dict)
|
||||
evidence: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RepositoryRenameRollbackRequest(BaseModel):
|
||||
expected_phase: str
|
||||
confirmation: str
|
||||
|
||||
|
||||
class RepositoryRenameOperationRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
old_slug: str
|
||||
new_slug: str
|
||||
expected_forge_repository_id: int
|
||||
expected_source_commit: str
|
||||
expected_default_branch: str
|
||||
actor: str
|
||||
phase_changed_at: datetime
|
||||
preflighted_at: datetime | None
|
||||
preflight_expires_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
rolled_back_at: datetime | None
|
||||
evidence: dict[str, Any]
|
||||
error_code: str | None
|
||||
error_message: str | None
|
||||
error_details: dict[str, Any] | None
|
||||
error_at: datetime | None
|
||||
no_op: bool = False
|
||||
|
||||
|
||||
class RepositoryRenameVerificationRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
phase: str
|
||||
ok: bool
|
||||
checks: list[dict[str, Any]]
|
||||
baseline_checksum: str
|
||||
current_checksum: str
|
||||
|
||||
|
||||
class RepositoryRenameRollbackPreflightRead(BaseModel):
|
||||
operation_id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
rollback_from_phase: str
|
||||
safe_to_rollback: bool
|
||||
blockers: list[dict[str, Any]]
|
||||
irreversible: list[dict[str, Any]]
|
||||
operation: RepositoryRenameOperationRead
|
||||
203
api/services/forge_repository.py
Normal file
203
api/services/forge_repository.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Narrow Forgejo repository boundary used by repository rename operations.
|
||||
|
||||
The lifecycle service depends on this protocol instead of calling Forgejo
|
||||
directly. Tests replace it with an in-memory gateway, which makes interruption
|
||||
and resume tests deterministic and guarantees that API tests cannot rename a
|
||||
live repository.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from api.config import settings
|
||||
from api.services.forge_credential import forge_read_token
|
||||
|
||||
|
||||
WRITE_TOKEN_ENV = "FORGE_RENAME_TOKEN"
|
||||
WRITE_TOKEN_FILE_ENV = "FORGE_RENAME_TOKEN_FILE"
|
||||
|
||||
|
||||
class ForgeRepositoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryUnreadable(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
class ForgeRepositoryConflict(ForgeRepositoryError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForgeRepositorySnapshot:
|
||||
repository_id: int
|
||||
owner: str
|
||||
name: str
|
||||
full_name: str
|
||||
default_branch: str
|
||||
head_commit: str
|
||||
clone_url: str | None
|
||||
html_url: str | None
|
||||
projection_readable: bool
|
||||
projection_source_present: bool
|
||||
projection_entry_count: int | None
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"repository_id": self.repository_id,
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"full_name": self.full_name,
|
||||
"default_branch": self.default_branch,
|
||||
"head_commit": self.head_commit,
|
||||
"clone_url": self.clone_url,
|
||||
"html_url": self.html_url,
|
||||
"projection_readable": self.projection_readable,
|
||||
"projection_source_present": self.projection_source_present,
|
||||
"projection_entry_count": self.projection_entry_count,
|
||||
}
|
||||
|
||||
|
||||
class ForgeRepositoryGateway(Protocol):
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None: ...
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot: ...
|
||||
|
||||
|
||||
def _write_token() -> str | None:
|
||||
path = os.environ.get(WRITE_TOKEN_FILE_ENV)
|
||||
if path:
|
||||
try:
|
||||
return Path(path).read_text(encoding="utf-8").strip() or None
|
||||
except OSError:
|
||||
return None
|
||||
return (os.environ.get(WRITE_TOKEN_ENV) or "").strip() or None
|
||||
|
||||
|
||||
class ForgejoRepositoryGateway:
|
||||
"""Forgejo v1 API adapter with separate read and rename credentials."""
|
||||
|
||||
@staticmethod
|
||||
def _headers(token: str | None) -> dict[str, str]:
|
||||
return {"Authorization": f"token {token}"} if token else {}
|
||||
|
||||
@staticmethod
|
||||
def _api(instance: str, owner: str, name: str) -> str:
|
||||
base = instance.rstrip("/")
|
||||
return f"{base}/api/v1/repos/{quote(owner, safe='')}/{quote(name, safe='')}"
|
||||
|
||||
async def _inspect_with_token(
|
||||
self, *, instance: str, owner: str, name: str, token: str | None
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
url = self._api(instance, owner, name)
|
||||
timeout = settings.repository_rename_forge_timeout_seconds
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, headers=self._headers(token))
|
||||
if response.status_code == 404:
|
||||
if token is None:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
"Forge returned not-found without an authenticated absence proof"
|
||||
)
|
||||
return None
|
||||
response.raise_for_status()
|
||||
repo = response.json()
|
||||
branch_name = str(repo.get("default_branch") or "")
|
||||
if not branch_name:
|
||||
raise ForgeRepositoryUnreadable("Forge repository has no default branch")
|
||||
branch = await client.get(
|
||||
f"{url}/branches/{quote(branch_name, safe='')}",
|
||||
headers=self._headers(token),
|
||||
)
|
||||
branch.raise_for_status()
|
||||
branch_data = branch.json()
|
||||
commit = branch_data.get("commit") or {}
|
||||
head = str(commit.get("id") or commit.get("sha") or "")
|
||||
if not head:
|
||||
raise ForgeRepositoryUnreadable("Forge default branch has no readable head")
|
||||
projection = await client.get(
|
||||
f"{url}/contents/workplans",
|
||||
params={"ref": head},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
source_present = projection.status_code == 200
|
||||
if projection.status_code not in {200, 404}:
|
||||
projection.raise_for_status()
|
||||
entries = projection.json() if source_present else None
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository inspection failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
return ForgeRepositorySnapshot(
|
||||
repository_id=int(repo["id"]),
|
||||
owner=str((repo.get("owner") or {}).get("login") or owner),
|
||||
name=str(repo["name"]),
|
||||
full_name=str(repo.get("full_name") or f"{owner}/{name}"),
|
||||
default_branch=branch_name,
|
||||
head_commit=head,
|
||||
clone_url=repo.get("clone_url"),
|
||||
html_url=repo.get("html_url"),
|
||||
projection_readable=True,
|
||||
projection_source_present=source_present,
|
||||
projection_entry_count=(len(entries) if isinstance(entries, list) else None),
|
||||
)
|
||||
|
||||
async def inspect(
|
||||
self, *, instance: str, owner: str, name: str
|
||||
) -> ForgeRepositorySnapshot | None:
|
||||
return await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=name, token=forge_read_token()
|
||||
)
|
||||
|
||||
async def rename(
|
||||
self, *, instance: str, owner: str, old_name: str, new_name: str
|
||||
) -> ForgeRepositorySnapshot:
|
||||
token = _write_token()
|
||||
if not token:
|
||||
raise ForgeRepositoryUnreadable("Forge rename credential is unavailable")
|
||||
url = self._api(instance, owner, old_name)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=settings.repository_rename_forge_timeout_seconds
|
||||
) as client:
|
||||
response = await client.patch(
|
||||
url,
|
||||
json={"name": new_name},
|
||||
headers=self._headers(token),
|
||||
)
|
||||
if response.status_code in {409, 422}:
|
||||
raise ForgeRepositoryConflict("Forge rejected the target repository name")
|
||||
response.raise_for_status()
|
||||
except ForgeRepositoryError:
|
||||
raise
|
||||
except httpx.HTTPError as exc:
|
||||
raise ForgeRepositoryUnreadable(
|
||||
f"Forge repository rename failed ({type(exc).__name__})"
|
||||
) from exc
|
||||
renamed = await self._inspect_with_token(
|
||||
instance=instance, owner=owner, name=new_name, token=token
|
||||
)
|
||||
if renamed is None:
|
||||
raise ForgeRepositoryUnreadable("Renamed Forge repository is not readable")
|
||||
return renamed
|
||||
|
||||
|
||||
_gateway = ForgejoRepositoryGateway()
|
||||
|
||||
|
||||
def get_forge_repository_gateway() -> ForgeRepositoryGateway:
|
||||
return _gateway
|
||||
1325
api/services/repository_rename.py
Normal file
1325
api/services/repository_rename.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue