This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user