"""Provider-specific URL derivation for public Labyricorn project repositories.""" from __future__ import annotations from dataclasses import dataclass import ipaddress import re from urllib.parse import quote, urlsplit, urlunsplit class ProjectProviderError(ValueError): """Raised when a public repository URL cannot be derived safely.""" @dataclass(frozen=True) class RepositoryUrls: provider: str repository: str web_url: str api_url: str def validate_public_https_url(value: str, field: str) -> str: value = value.strip().rstrip("/") parsed = urlsplit(value) if ( parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password ): raise ProjectProviderError(f"{field} must be a credential-free HTTPS URL") if parsed.hostname.lower() == "localhost": raise ProjectProviderError(f"{field} must not target localhost") try: address = ipaddress.ip_address(parsed.hostname) except ValueError: pass else: if not address.is_global: raise ProjectProviderError(f"{field} must not target a private or local address") return value def repository_provider(web_url: str) -> str: hostname = (urlsplit(web_url).hostname or "").lower() return "github" if hostname == "github.com" else "gitea" def derive_repository_urls(value: str) -> RepositoryUrls: """Derive Git and API URLs from one GitHub or Gitea repository web URL.""" web_url = validate_public_https_url(value, "Repository URL") parsed = urlsplit(web_url) hostname = (parsed.hostname or "").lower() if hostname == "gitlab.com": raise ProjectProviderError( "GitLab repositories are not supported; use a public GitHub or Gitea URL" ) if parsed.query or parsed.fragment: raise ProjectProviderError("Repository URL must not contain a query or fragment") path = parsed.path.rstrip("/") if path.lower().endswith(".git"): path = path[:-4] parts = [part for part in path.split("/") if part] if len(parts) != 2: raise ProjectProviderError( "Repository URL must identify one owner and repository, such as " "https://github.com/owner/repository" ) owner, repository_name = parts if any( part in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9_.-]+", part) for part in (owner, repository_name) ): raise ProjectProviderError( "Repository owner and name contain unsupported URL characters" ) normalized_path = f"/{owner}/{repository_name}" normalized_web = urlunsplit((parsed.scheme, parsed.netloc, normalized_path, "", "")) provider = repository_provider(normalized_web) if provider == "github": api_url = ( "https://api.github.com/repos/" f"{quote(owner, safe='')}/{quote(repository_name, safe='')}" ) else: api_url = urlunsplit( ( parsed.scheme, parsed.netloc, f"/api/v1/repos/{quote(owner, safe='')}/{quote(repository_name, safe='')}", "", "", ) ) return RepositoryUrls(provider, f"{normalized_web}.git", normalized_web, api_url) def repository_readme_url(web_url: str, branch: str) -> str: encoded_branch = quote(branch, safe="") if repository_provider(web_url) == "github": return f"{web_url}/blob/{encoded_branch}/README.md" return f"{web_url}/src/branch/{encoded_branch}/README.md"