"""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