105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
"""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"
|