1350 lines
49 KiB
Python
1350 lines
49 KiB
Python
"""Typed, bounded Gitea client primitives for registered repositories."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
import time
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from threading import RLock
|
|
from typing import Any, Protocol, cast
|
|
from urllib.parse import quote, urljoin, urlparse
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
|
|
class GiteaError(RuntimeError):
|
|
"""Stable connector error that never contains credentials or raw headers."""
|
|
|
|
def __init__(
|
|
self,
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
category: str | None = None,
|
|
retryable: bool | None = None,
|
|
safe_to_retry: bool | None = None,
|
|
upstream_status: int | None = None,
|
|
expected_state: str | None = None,
|
|
actual_state: str | None = None,
|
|
recovery_hint: str | None = None,
|
|
) -> None:
|
|
self.code = code
|
|
transient = code in {
|
|
"GITEA_UNAVAILABLE",
|
|
"GITEA_RATE_LIMITED",
|
|
"GITEA_UPSTREAM_UNAVAILABLE",
|
|
}
|
|
self.category = category or ("upstream" if transient else "operation")
|
|
self.retryable = transient if retryable is None else retryable
|
|
self.safe_to_retry = self.retryable if safe_to_retry is None else safe_to_retry
|
|
self.upstream_status = upstream_status
|
|
self.expected_state = expected_state
|
|
self.actual_state = actual_state
|
|
self.recovery_hint = recovery_hint
|
|
super().__init__(message)
|
|
|
|
def envelope(
|
|
self,
|
|
*,
|
|
operation: str,
|
|
correlation_id: str | None = None,
|
|
session_id: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Return the shared sanitized machine-readable recovery contract."""
|
|
|
|
return {
|
|
"code": self.code,
|
|
"category": self.category,
|
|
"message": str(self),
|
|
"operation": operation,
|
|
"subsystem": "upstream_gitea",
|
|
"retryable": self.retryable,
|
|
"safeToRetry": self.safe_to_retry,
|
|
"correlationId": correlation_id,
|
|
"sessionId": session_id,
|
|
"upstreamStatus": self.upstream_status,
|
|
"expectedState": self.expected_state,
|
|
"actualState": self.actual_state,
|
|
"recoveryHint": self.recovery_hint,
|
|
}
|
|
|
|
|
|
def _access_denied(status: int, method: str, path: str) -> GiteaError:
|
|
"""Diagnose access failures without reflecting upstream content or request data."""
|
|
if status == 401:
|
|
return GiteaError(
|
|
"GITEA_ACCESS_DENIED",
|
|
"Gitea rejected the configured credential (HTTP 401)",
|
|
category="authentication",
|
|
upstream_status=status,
|
|
expected_state="Valid configured Gitea credential",
|
|
actual_state="Credential rejected by Gitea",
|
|
recovery_hint=(
|
|
"Verify the mounted credential is valid and belongs to the configured account; "
|
|
"do not retry unchanged."
|
|
),
|
|
)
|
|
scope = {("POST", "user/repos"): "write:user", ("GET", "user/repos"): "read:user"}.get(
|
|
(method, path)
|
|
)
|
|
hint = (
|
|
"Verify the configured account permissions and credential scopes; do not retry unchanged."
|
|
)
|
|
if scope:
|
|
hint = (
|
|
f"Verify the configured credential includes {scope} for this endpoint, "
|
|
"and check account permissions and server policy; do not retry unchanged."
|
|
)
|
|
return GiteaError(
|
|
"GITEA_ACCESS_DENIED",
|
|
"Gitea refused the operation (HTTP 403); verify credential scopes and account permissions",
|
|
category="permission",
|
|
upstream_status=status,
|
|
expected_state="Credential scopes and account permissions authorize the operation",
|
|
actual_state="Operation forbidden by Gitea",
|
|
recovery_hint=hint,
|
|
)
|
|
|
|
|
|
class GiteaSettings(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
base_url: str = Field(min_length=1, max_length=2048)
|
|
token_reference: str = Field(min_length=1, max_length=512)
|
|
timeout_seconds: float = Field(default=15.0, gt=0, le=60)
|
|
max_page_size: int = Field(default=100, ge=1, le=100)
|
|
max_response_bytes: int = Field(default=524288, ge=1024, le=4 * 1024 * 1024)
|
|
allow_insecure_http_for_tests: bool = False
|
|
|
|
@field_validator("base_url")
|
|
@classmethod
|
|
def validate_base_url(cls, value: str) -> str:
|
|
parsed = urlparse(value.strip().rstrip("/"))
|
|
if parsed.scheme not in {"https", "http"} or not parsed.hostname:
|
|
raise ValueError("Gitea base_url must be an absolute HTTP(S) URL")
|
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise ValueError("Gitea base_url must not contain credentials or query data")
|
|
hostname = (parsed.hostname or "").casefold()
|
|
if hostname in {"localhost", "metadata", "metadata.google.internal"}:
|
|
raise ValueError("Gitea base_url host is not allowed")
|
|
try:
|
|
address = ipaddress.ip_address(hostname)
|
|
except ValueError:
|
|
address = None
|
|
if address is not None and (
|
|
address.is_private
|
|
or address.is_loopback
|
|
or address.is_link_local
|
|
or address.is_reserved
|
|
or address.is_unspecified
|
|
or address.is_multicast
|
|
):
|
|
raise ValueError("Gitea base_url host is not allowed")
|
|
return value.strip().rstrip("/")
|
|
|
|
@model_validator(mode="after")
|
|
def require_tls(self) -> GiteaSettings:
|
|
if urlparse(self.base_url).scheme != "https" and not self.allow_insecure_http_for_tests:
|
|
raise ValueError("Gitea base_url must use HTTPS")
|
|
if not self.token_reference.startswith(("docker-secret://", "/run/secrets/")):
|
|
raise ValueError("token_reference must name a mounted secret, not contain a token")
|
|
return self
|
|
|
|
|
|
class RegisteredRepository(BaseModel):
|
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
|
|
repository_ref: str = Field(pattern=r"^[a-z0-9][a-z0-9-]{0,127}$")
|
|
project_id: UUID
|
|
owner: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$")
|
|
name: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$")
|
|
source_uri: str = Field(min_length=1, max_length=2048)
|
|
private: bool = False
|
|
|
|
@property
|
|
def qualified_name(self) -> str:
|
|
return f"{self.owner}/{self.name}"
|
|
|
|
@field_validator("source_uri")
|
|
@classmethod
|
|
def validate_source_uri(cls, value: str) -> str:
|
|
if not value.startswith("itworx-git://") or any(ord(char) < 0x20 for char in value):
|
|
raise ValueError("source_uri must be a safe registered Gitea URI")
|
|
return value
|
|
|
|
|
|
class TokenProvider(Protocol):
|
|
def get_token(self, reference: str) -> str: ...
|
|
|
|
|
|
class FileTokenProvider:
|
|
"""Read-only provider for Docker secrets mounted beneath /run/secrets."""
|
|
|
|
def get_token(self, reference: str) -> str:
|
|
if reference.startswith("docker-secret://"):
|
|
name = reference.removeprefix("docker-secret://")
|
|
path = Path("/run/secrets") / name
|
|
else:
|
|
path = Path(reference)
|
|
root = Path("/run/secrets").resolve()
|
|
try:
|
|
resolved = path.resolve()
|
|
resolved.relative_to(root)
|
|
except (OSError, ValueError) as exc:
|
|
raise GiteaError(
|
|
"GITEA_SECRET_REFERENCE_INVALID", "secret reference is outside /run/secrets"
|
|
) from exc
|
|
try:
|
|
token = resolved.read_text(encoding="utf-8").strip()
|
|
except OSError as exc:
|
|
raise GiteaError(
|
|
"GITEA_SECRET_UNAVAILABLE", "configured Gitea secret is unavailable"
|
|
) from exc
|
|
if not token or len(token) > 4096 or any(ord(char) < 0x20 for char in token):
|
|
raise GiteaError("GITEA_SECRET_INVALID", "configured Gitea secret is invalid")
|
|
return token
|
|
|
|
|
|
class StaticTokenProvider:
|
|
"""Deterministic fake-only provider; never serializes the token."""
|
|
|
|
def __init__(self, token: str) -> None:
|
|
self._token = token
|
|
|
|
def get_token(self, reference: str) -> str:
|
|
if not reference.startswith(("docker-secret://", "/run/secrets/")):
|
|
raise GiteaError("GITEA_SECRET_REFERENCE_INVALID", "invalid secret reference")
|
|
return self._token
|
|
|
|
|
|
class GiteaRepositoryRegistry:
|
|
"""Exact project-scoped mapping from opaque repository refs to Gitea coordinates."""
|
|
|
|
def __init__(
|
|
self,
|
|
repositories: tuple[RegisteredRepository, ...] = (),
|
|
*,
|
|
project_aliases: dict[UUID, UUID] | None = None,
|
|
) -> None:
|
|
self._repositories = {item.repository_ref: item for item in repositories}
|
|
self._project_aliases = dict(project_aliases or {})
|
|
self._lock = RLock()
|
|
if len(self._repositories) != len(repositories):
|
|
raise ValueError("repository_ref values must be unique")
|
|
if any(alias == target for alias, target in self._project_aliases.items()):
|
|
raise ValueError("repository project aliases cannot target themselves")
|
|
|
|
def _project_id(self, project_id: UUID) -> UUID:
|
|
return self._project_aliases.get(project_id, project_id)
|
|
|
|
def add_discovered(
|
|
self,
|
|
project_id: UUID,
|
|
project_slug: str,
|
|
repositories: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Add repositories visible to the scoped read token without changing stable refs."""
|
|
with self._lock:
|
|
registered_coordinates = {
|
|
item.qualified_name.casefold() for item in self._repositories.values()
|
|
}
|
|
name_counts: dict[str, int] = {}
|
|
for payload in repositories:
|
|
discovered_name = str(payload.get("name", "")).casefold()
|
|
if discovered_name:
|
|
name_counts[discovered_name] = name_counts.get(discovered_name, 0) + 1
|
|
for payload in repositories:
|
|
owner_payload = payload.get("owner")
|
|
owner = (
|
|
str(owner_payload.get("login", "")) if isinstance(owner_payload, dict) else ""
|
|
)
|
|
name = str(payload.get("name", ""))
|
|
if not owner or not name or f"{owner}/{name}".casefold() in registered_coordinates:
|
|
continue
|
|
base = re.sub(r"[^a-z0-9]+", "-", name.casefold()).strip("-")[:120]
|
|
if not base:
|
|
continue
|
|
owner_part = re.sub(r"[^a-z0-9]+", "-", owner.casefold()).strip("-")
|
|
repository_ref = f"all-{base}"[:128]
|
|
if name_counts.get(name.casefold(), 0) > 1 or repository_ref in self._repositories:
|
|
repository_ref = f"all-{owner_part}-{base}"[:128]
|
|
if not repository_ref or repository_ref in self._repositories:
|
|
continue
|
|
self._repositories[repository_ref] = RegisteredRepository(
|
|
repository_ref=repository_ref,
|
|
project_id=project_id,
|
|
owner=owner,
|
|
name=name,
|
|
source_uri=f"itworx-git://{project_slug}/{repository_ref}",
|
|
private=bool(payload.get("private", True)),
|
|
)
|
|
registered_coordinates.add(f"{owner}/{name}".casefold())
|
|
|
|
def list(self, project_id: UUID) -> list[RegisteredRepository]:
|
|
project_id = self._project_id(project_id)
|
|
with self._lock:
|
|
return sorted(
|
|
(item for item in self._repositories.values() if item.project_id == project_id),
|
|
key=lambda item: item.repository_ref,
|
|
)
|
|
|
|
def remove_discovered(self, project_id: UUID, qualified_name: str) -> None:
|
|
"""Remove only a matching dynamic registration after create rollback."""
|
|
|
|
project_id = self._project_id(project_id)
|
|
with self._lock:
|
|
for repository_ref, item in tuple(self._repositories.items()):
|
|
if (
|
|
item.project_id == project_id
|
|
and item.qualified_name.casefold() == qualified_name.casefold()
|
|
and repository_ref.startswith("all-")
|
|
):
|
|
self._repositories.pop(repository_ref, None)
|
|
|
|
def resolve(self, project_id: UUID, repository_ref: str) -> RegisteredRepository:
|
|
project_id = self._project_id(project_id)
|
|
with self._lock:
|
|
item = self._repositories.get(repository_ref)
|
|
if item is None:
|
|
qualified_name = repository_ref.casefold()
|
|
matches = [
|
|
candidate
|
|
for candidate in self._repositories.values()
|
|
if candidate.project_id == project_id
|
|
and candidate.qualified_name.casefold() == qualified_name
|
|
]
|
|
item = matches[0] if len(matches) == 1 else None
|
|
if item is None or item.project_id != project_id:
|
|
raise GiteaError(
|
|
"GITEA_REPOSITORY_NOT_REGISTERED", "repository is not registered for this project"
|
|
)
|
|
return item
|
|
|
|
|
|
class GiteaClient:
|
|
"""Small bounded Gitea API client with same-origin redirect enforcement."""
|
|
|
|
_path_part = re.compile(r"^[^/\\?#\x00-\x1f\x7f]+$")
|
|
|
|
def __init__(
|
|
self,
|
|
settings: GiteaSettings,
|
|
token_provider: TokenProvider,
|
|
*,
|
|
transport: httpx.BaseTransport | None = None,
|
|
) -> None:
|
|
self.settings = settings
|
|
self._token_provider = token_provider
|
|
self._client = httpx.Client(
|
|
base_url=f"{settings.base_url}/api/v1/",
|
|
timeout=settings.timeout_seconds,
|
|
follow_redirects=False,
|
|
transport=transport,
|
|
)
|
|
self._merge_strategy_key: str | None = None
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
def _path(self, *parts: str) -> str:
|
|
if any(not part or not self._path_part.fullmatch(part) for part in parts):
|
|
raise GiteaError("GITEA_TARGET_INVALID", "Gitea path segment is invalid")
|
|
# Keep paths relative: a leading slash would make httpx discard the
|
|
# /api/v1/ base path and silently call the Gitea web routes instead.
|
|
return "/".join(parts)
|
|
|
|
def _request(
|
|
self,
|
|
path: str,
|
|
params: Mapping[str, str | int] | None = None,
|
|
*,
|
|
method: str = "GET",
|
|
json_body: Mapping[str, Any] | None = None,
|
|
timeout_seconds: float | None = None,
|
|
max_attempts: int | None = None,
|
|
) -> Any:
|
|
token = self._token_provider.get_token(self.settings.token_reference)
|
|
headers = {"Authorization": f"token {token}", "Accept": "application/json"}
|
|
if json_body is not None:
|
|
encoded_body = json.dumps(json_body, separators=(",", ":")).encode("utf-8")
|
|
if len(encoded_body) > self.settings.max_response_bytes:
|
|
raise GiteaError(
|
|
"GITEA_REQUEST_TOO_LARGE", "Gitea request exceeds the connector budget"
|
|
)
|
|
method = method.upper()
|
|
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
|
raise GiteaError("GITEA_METHOD_DENIED", "Gitea method is not allowed")
|
|
attempts = max_attempts if max_attempts is not None else (3 if method == "GET" else 1)
|
|
if not 1 <= attempts <= 3:
|
|
raise GiteaError("GITEA_REQUEST_INVALID", "Gitea retry budget is invalid")
|
|
response: httpx.Response | None = None
|
|
for attempt in range(attempts):
|
|
try:
|
|
request_options: dict[str, Any] = {}
|
|
if timeout_seconds is not None:
|
|
request_options["timeout"] = timeout_seconds
|
|
response = self._client.request(
|
|
method,
|
|
path,
|
|
params=params,
|
|
json=json_body,
|
|
headers=headers,
|
|
**request_options,
|
|
)
|
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
if attempt + 1 < attempts:
|
|
time.sleep(0.1 * (attempt + 1))
|
|
continue
|
|
raise GiteaError(
|
|
"GITEA_UNAVAILABLE",
|
|
"Gitea target is unavailable",
|
|
retryable=True,
|
|
safe_to_retry=method == "GET",
|
|
recovery_hint="Retry the unchanged read after the bounded backoff.",
|
|
) from exc
|
|
if response.status_code in {429, 502, 503, 504} and attempt + 1 < attempts:
|
|
time.sleep(0.1 * (attempt + 1))
|
|
continue
|
|
break
|
|
if response is None: # pragma: no cover - defensive invariant
|
|
raise GiteaError("GITEA_UNAVAILABLE", "Gitea target is unavailable")
|
|
try:
|
|
if 300 <= response.status_code < 400:
|
|
if method != "GET":
|
|
raise GiteaError(
|
|
"GITEA_REDIRECT_DENIED", "Gitea write redirects are not followed"
|
|
)
|
|
location = response.headers.get("location")
|
|
if not location:
|
|
raise GiteaError(
|
|
"GITEA_REDIRECT_DENIED", "Gitea redirect did not provide a location"
|
|
)
|
|
target = urlparse(urljoin(str(response.url), location))
|
|
base = urlparse(self.settings.base_url)
|
|
if (target.scheme, target.hostname, target.port) != (
|
|
base.scheme,
|
|
base.hostname,
|
|
base.port,
|
|
):
|
|
raise GiteaError(
|
|
"GITEA_REDIRECT_DENIED", "Gitea redirect leaves the configured origin"
|
|
)
|
|
redirected_response: httpx.Response | None = None
|
|
for redirect_attempt in range(3):
|
|
try:
|
|
redirected_response = self._client.get(target.geturl(), headers=headers)
|
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
if redirect_attempt < 2:
|
|
time.sleep(0.1 * (redirect_attempt + 1))
|
|
continue
|
|
raise GiteaError(
|
|
"GITEA_UNAVAILABLE",
|
|
"Gitea target is unavailable",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
recovery_hint="Retry the unchanged read after the bounded backoff.",
|
|
) from exc
|
|
if (
|
|
redirected_response.status_code in {429, 502, 503, 504}
|
|
and redirect_attempt < 2
|
|
):
|
|
time.sleep(0.1 * (redirect_attempt + 1))
|
|
continue
|
|
break
|
|
if redirected_response is None: # pragma: no cover - defensive invariant
|
|
raise GiteaError("GITEA_UNAVAILABLE", "Gitea target is unavailable")
|
|
response = redirected_response
|
|
if 300 <= response.status_code < 400:
|
|
raise GiteaError("GITEA_REDIRECT_DENIED", "Gitea returned multiple redirects")
|
|
except GiteaError:
|
|
raise
|
|
if len(response.content) > self.settings.max_response_bytes:
|
|
raise GiteaError(
|
|
"GITEA_RESPONSE_TOO_LARGE", "Gitea response exceeds the connector budget"
|
|
)
|
|
if response.status_code == 404:
|
|
raise GiteaError("GITEA_NOT_FOUND", "Gitea resource was not found")
|
|
if response.status_code in {401, 403}:
|
|
raise _access_denied(response.status_code, method, path)
|
|
if response.status_code == 409:
|
|
raise GiteaError(
|
|
"GITEA_CONFLICT",
|
|
"Gitea reported a state conflict",
|
|
upstream_status=409,
|
|
recovery_hint="Refresh branch and pull-request state before retrying.",
|
|
)
|
|
if response.status_code == 422:
|
|
raise GiteaError(
|
|
"GITEA_VALIDATION_FAILED",
|
|
"Gitea rejected the request validation",
|
|
upstream_status=422,
|
|
)
|
|
if response.status_code == 429:
|
|
raise GiteaError(
|
|
"GITEA_RATE_LIMITED",
|
|
"Gitea temporarily rate limited the request",
|
|
retryable=True,
|
|
safe_to_retry=method == "GET",
|
|
upstream_status=429,
|
|
recovery_hint="Retry the unchanged read after backoff.",
|
|
)
|
|
if response.status_code in {502, 503, 504}:
|
|
raise GiteaError(
|
|
"GITEA_UPSTREAM_UNAVAILABLE",
|
|
"Gitea is temporarily unavailable",
|
|
retryable=True,
|
|
safe_to_retry=method == "GET",
|
|
upstream_status=response.status_code,
|
|
recovery_hint="Retry the unchanged read after the bounded backoff.",
|
|
)
|
|
# Gitea also uses HTTP 405 for a valid state-changing endpoint whose
|
|
# transition is temporarily blocked (for example a PR whose required
|
|
# checks have not all completed). Treating that as an unsupported API
|
|
# produced a misleading permanent-capability error for retryable PRs.
|
|
if response.status_code == 405:
|
|
raise GiteaError(
|
|
"GITEA_STATE_TRANSITION_DENIED",
|
|
"Gitea rejected the requested state transition",
|
|
)
|
|
if response.status_code == 501:
|
|
raise GiteaError("GITEA_UNSUPPORTED", "Gitea API capability is unsupported")
|
|
if response.status_code >= 400:
|
|
raise GiteaError("GITEA_REQUEST_FAILED", "Gitea returned an unsuccessful response")
|
|
if response.status_code == 204 or not response.content:
|
|
return {}
|
|
try:
|
|
return response.json()
|
|
except (ValueError, json.JSONDecodeError) as exc:
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "Gitea returned invalid JSON") from exc
|
|
|
|
def repository(self, repository: RegisteredRepository) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any], self._request(self._path("repos", repository.owner, repository.name))
|
|
)
|
|
|
|
def authenticated_user(self) -> dict[str, Any]:
|
|
return cast(dict[str, Any], self._request(self._path("user")))
|
|
|
|
def create_repository(
|
|
self,
|
|
*,
|
|
name: str,
|
|
description: str,
|
|
private: bool,
|
|
auto_init: bool,
|
|
default_branch: str,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("user", "repos"),
|
|
method="POST",
|
|
json_body={
|
|
"name": name,
|
|
"description": description,
|
|
"private": private,
|
|
"auto_init": auto_init,
|
|
"default_branch": default_branch,
|
|
},
|
|
),
|
|
)
|
|
|
|
def delete_repository(self, repository: RegisteredRepository) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(self._path("repos", repository.owner, repository.name), method="DELETE"),
|
|
)
|
|
|
|
def accessible_private_repositories(self, page: int, limit: int) -> list[dict[str, Any]]:
|
|
payload = self._request(
|
|
self._path("repos", "search"),
|
|
{"page": page, "limit": limit, "private": "true"},
|
|
)
|
|
if isinstance(payload, dict):
|
|
payload = payload.get("data")
|
|
return self._as_list(payload)
|
|
|
|
def authenticated_user_repositories(self, page: int, limit: int) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("user", "repos"),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def branches(
|
|
self, repository: RegisteredRepository, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "branches"),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def branch_protections(self, repository: RegisteredRepository) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "branch_protections")
|
|
)
|
|
)
|
|
|
|
def branch_protection(self, repository: RegisteredRepository, rule_name: str) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"branch_protections",
|
|
quote(rule_name, safe=""),
|
|
)
|
|
),
|
|
)
|
|
|
|
def create_branch_protection(
|
|
self, repository: RegisteredRepository, settings: Mapping[str, Any]
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "branch_protections"),
|
|
method="POST",
|
|
json_body=settings,
|
|
),
|
|
)
|
|
|
|
def update_branch_protection(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
rule_name: str,
|
|
settings: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"branch_protections",
|
|
quote(rule_name, safe=""),
|
|
),
|
|
method="PATCH",
|
|
json_body=settings,
|
|
),
|
|
)
|
|
|
|
def delete_branch_protection(
|
|
self, repository: RegisteredRepository, rule_name: str
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"branch_protections",
|
|
quote(rule_name, safe=""),
|
|
),
|
|
method="DELETE",
|
|
),
|
|
)
|
|
|
|
def commits(
|
|
self, repository: RegisteredRepository, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "commits"),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def commit(self, repository: RegisteredRepository, ref: str) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"git",
|
|
"commits",
|
|
quote(ref, safe=""),
|
|
),
|
|
{"stat": "true", "verification": "true", "files": "true"},
|
|
),
|
|
)
|
|
|
|
def compare(self, repository: RegisteredRepository, base: str, head: str) -> dict[str, Any]:
|
|
base_head = quote(f"{base}...{head}", safe="")
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "compare", base_head)
|
|
),
|
|
)
|
|
|
|
def releases(
|
|
self, repository: RegisteredRepository, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "releases"),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def issues(
|
|
self, repository: RegisteredRepository, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "issues"),
|
|
{"page": page, "limit": limit, "state": "all"},
|
|
)
|
|
)
|
|
|
|
def search_issues(self, *, state: str, page: int, limit: int) -> list[dict[str, Any]]:
|
|
"""Search visible issues across repositories in one bounded Gitea request."""
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", "issues", "search"),
|
|
{"page": page, "limit": limit, "state": state, "type": "issues"},
|
|
)
|
|
)
|
|
|
|
def pull_requests(
|
|
self, repository: RegisteredRepository, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls"),
|
|
{"page": page, "limit": limit, "state": "all"},
|
|
)
|
|
)
|
|
|
|
def pull_request(self, repository: RegisteredRepository, index: int) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls", str(index))
|
|
),
|
|
)
|
|
|
|
def update_pull_request(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
index: int,
|
|
*,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
) -> dict[str, Any]:
|
|
changes: dict[str, Any] = {}
|
|
if title is not None:
|
|
changes["title"] = title
|
|
if body is not None:
|
|
changes["body"] = body
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls", str(index)),
|
|
method="PATCH",
|
|
json_body=changes,
|
|
),
|
|
)
|
|
|
|
def close_pull_request(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
index: int,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls", str(index)),
|
|
method="PATCH",
|
|
json_body={"state": "closed"},
|
|
),
|
|
)
|
|
|
|
def reopen_pull_request(self, repository: RegisteredRepository, index: int) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls", str(index)),
|
|
method="PATCH",
|
|
json_body={"state": "open"},
|
|
),
|
|
)
|
|
|
|
def merge_pull_request(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
index: int,
|
|
*,
|
|
method: str,
|
|
head_commit_id: str,
|
|
) -> dict[str, Any]:
|
|
strategy_key = self._merge_strategy_field()
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "pulls", str(index), "merge"
|
|
),
|
|
method="POST",
|
|
json_body={
|
|
strategy_key: method,
|
|
"head_commit_id": head_commit_id,
|
|
"force_merge": False,
|
|
"merge_when_checks_succeed": False,
|
|
"delete_branch_after_merge": False,
|
|
},
|
|
),
|
|
)
|
|
|
|
def _merge_strategy_field(self) -> str:
|
|
"""Resolve the versioned Gitea merge field before the single write attempt."""
|
|
|
|
if self._merge_strategy_key is not None:
|
|
return self._merge_strategy_key
|
|
payload = self._request(self._path("version"))
|
|
version = payload.get("version") if isinstance(payload, dict) else None
|
|
legacy_match = re.fullmatch(r"1\.(14|25|26|27)(?:\.\d+)?(?:[-+].*)?", str(version or ""))
|
|
modern_match = re.fullmatch(r"(28)(?:\.\d+){1,2}(?:[-+].*)?", str(version or ""))
|
|
if legacy_match is None and modern_match is None:
|
|
raise GiteaError(
|
|
"GITEA_VERSION_UNSUPPORTED",
|
|
(
|
|
"Gitea merge compatibility requires an identified "
|
|
"1.14, 1.25, 1.26, 1.27, or 28.x server"
|
|
),
|
|
)
|
|
self._merge_strategy_key = (
|
|
"Do" if legacy_match is not None and legacy_match.group(1) in {"14", "25"} else "do"
|
|
)
|
|
return self._merge_strategy_key
|
|
|
|
def update_pull_request_branch(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
index: int,
|
|
*,
|
|
style: str,
|
|
) -> dict[str, Any]:
|
|
if style not in {"merge", "rebase"}:
|
|
raise GiteaError("GITEA_TARGET_INVALID", "pull request update style is invalid")
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "pulls", str(index), "update"
|
|
),
|
|
{"style": style},
|
|
method="POST",
|
|
),
|
|
)
|
|
|
|
def pull_request_files(
|
|
self, repository: RegisteredRepository, index: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "pulls", str(index), "files"
|
|
),
|
|
{"page": 1, "limit": 100},
|
|
)
|
|
)
|
|
|
|
def commit_statuses(
|
|
self, repository: RegisteredRepository, ref: str, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"commits",
|
|
quote(ref, safe=""),
|
|
"statuses",
|
|
),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def file(
|
|
self, repository: RegisteredRepository, path: tuple[str, ...], ref: str | None = None
|
|
) -> dict[str, Any]:
|
|
if not path or any(
|
|
not self._path_part.fullmatch(part) or part in {".", ".."} for part in path
|
|
):
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository file path is invalid")
|
|
params = {"ref": ref} if ref else None
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "contents", *path), params
|
|
),
|
|
)
|
|
|
|
def contents(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
path: tuple[str, ...] = (),
|
|
ref: str | None = None,
|
|
) -> Any:
|
|
if any(not self._path_part.fullmatch(part) or part in {".", ".."} for part in path):
|
|
raise GiteaError("GITEA_PATH_INVALID", "repository directory path is invalid")
|
|
params = {"ref": ref} if ref else None
|
|
return self._request(
|
|
self._path("repos", repository.owner, repository.name, "contents", *path), params
|
|
)
|
|
|
|
def git_tree(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
ref: str,
|
|
*,
|
|
page: int = 1,
|
|
per_page: int = 1_000,
|
|
recursive: bool = True,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "git", "trees", quote(ref, safe="")
|
|
),
|
|
{
|
|
"recursive": "true" if recursive else "false",
|
|
"page": page,
|
|
"per_page": per_page,
|
|
},
|
|
),
|
|
)
|
|
|
|
def git_blob(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
sha: str,
|
|
*,
|
|
timeout_seconds: float | None = None,
|
|
max_attempts: int | None = None,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "git", "blobs", sha),
|
|
timeout_seconds=timeout_seconds,
|
|
max_attempts=max_attempts,
|
|
),
|
|
)
|
|
|
|
def pull_request_reviews(
|
|
self, repository: RegisteredRepository, index: int, page: int, limit: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "pulls", str(index), "reviews"
|
|
),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
)
|
|
|
|
def pull_request_review_comments(
|
|
self, repository: RegisteredRepository, index: int, review_id: int
|
|
) -> list[dict[str, Any]]:
|
|
return self._as_list(
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"pulls",
|
|
str(index),
|
|
"reviews",
|
|
str(review_id),
|
|
"comments",
|
|
)
|
|
)
|
|
)
|
|
|
|
def action_workflows(self, repository: RegisteredRepository) -> list[dict[str, Any]]:
|
|
payload = self._request(
|
|
self._path("repos", repository.owner, repository.name, "actions", "workflows")
|
|
)
|
|
if isinstance(payload, dict):
|
|
payload = payload.get("workflows")
|
|
return self._as_list(payload)
|
|
|
|
def dispatch_action_workflow(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
workflow_id: str,
|
|
*,
|
|
ref: str,
|
|
inputs: Mapping[str, str],
|
|
source_repository_id: int = 0,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"actions",
|
|
"workflows",
|
|
workflow_id,
|
|
"dispatches",
|
|
),
|
|
{
|
|
"return_run_details": "true",
|
|
"scoped_workflow_source_repo_id": source_repository_id,
|
|
},
|
|
method="POST",
|
|
json_body={"ref": f"refs/heads/{ref}", "inputs": dict(inputs)},
|
|
),
|
|
)
|
|
|
|
def action_runs(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
*,
|
|
branch: str,
|
|
page: int,
|
|
limit: int,
|
|
) -> list[dict[str, Any]]:
|
|
payload = self._request(
|
|
self._path("repos", repository.owner, repository.name, "actions", "runs"),
|
|
{"branch": branch, "page": page, "limit": limit},
|
|
)
|
|
if isinstance(payload, dict):
|
|
payload = payload.get("workflow_runs") or payload.get("runs")
|
|
return self._as_list(payload)
|
|
|
|
def repository_action_runs(
|
|
self, repository: RegisteredRepository, *, page: int = 1, limit: int = 100
|
|
) -> list[dict[str, Any]]:
|
|
payload = self._request(
|
|
self._path("repos", repository.owner, repository.name, "actions", "runs"),
|
|
{"page": page, "limit": limit},
|
|
)
|
|
if isinstance(payload, dict):
|
|
payload = payload.get("workflow_runs") or payload.get("runs")
|
|
return self._as_list(payload)
|
|
|
|
def rerun_action_run(self, repository: RegisteredRepository, run_id: int) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"actions",
|
|
"runs",
|
|
str(run_id),
|
|
"rerun",
|
|
),
|
|
method="POST",
|
|
),
|
|
)
|
|
|
|
def action_run(self, repository: RegisteredRepository, run_id: int) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "actions", "runs", str(run_id)
|
|
)
|
|
),
|
|
)
|
|
|
|
def action_run_jobs(
|
|
self, repository: RegisteredRepository, run_id: int
|
|
) -> list[dict[str, Any]]:
|
|
payload = self._request(
|
|
self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"actions",
|
|
"runs",
|
|
str(run_id),
|
|
"jobs",
|
|
),
|
|
{"page": 1, "limit": 100},
|
|
)
|
|
if isinstance(payload, dict):
|
|
payload = payload.get("jobs")
|
|
return self._as_list(payload)
|
|
|
|
def action_job_logs(
|
|
self, repository: RegisteredRepository, job_id: int, *, max_bytes: int = 100_000
|
|
) -> tuple[str, bool]:
|
|
token = self._token_provider.get_token(self.settings.token_reference)
|
|
headers = {"Authorization": f"token {token}", "Accept": "text/plain"}
|
|
path = self._path(
|
|
"repos",
|
|
repository.owner,
|
|
repository.name,
|
|
"actions",
|
|
"jobs",
|
|
str(job_id),
|
|
"logs",
|
|
)
|
|
response: httpx.Response | None = None
|
|
for attempt in range(3):
|
|
try:
|
|
response = self._client.get(path, headers=headers)
|
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
if attempt < 2:
|
|
time.sleep(0.1 * (attempt + 1))
|
|
continue
|
|
raise GiteaError(
|
|
"GITEA_UNAVAILABLE",
|
|
"Gitea target is unavailable",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
) from exc
|
|
if response.status_code in {429, 502, 503, 504} and attempt < 2:
|
|
time.sleep(0.1 * (attempt + 1))
|
|
continue
|
|
break
|
|
if response is None: # pragma: no cover - defensive invariant
|
|
raise GiteaError("GITEA_UNAVAILABLE", "Gitea target is unavailable")
|
|
if response.status_code == 404:
|
|
raise GiteaError("GITEA_NOT_FOUND", "Gitea resource was not found")
|
|
if response.status_code in {401, 403}:
|
|
raise _access_denied(response.status_code, "GET", path)
|
|
if response.status_code == 429:
|
|
raise GiteaError(
|
|
"GITEA_RATE_LIMITED",
|
|
"Gitea temporarily rate limited the request",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
upstream_status=429,
|
|
recovery_hint="Retry the unchanged log read after backoff.",
|
|
)
|
|
if response.status_code in {502, 503, 504}:
|
|
raise GiteaError(
|
|
"GITEA_UPSTREAM_UNAVAILABLE",
|
|
"Gitea is temporarily unavailable",
|
|
retryable=True,
|
|
safe_to_retry=True,
|
|
upstream_status=response.status_code,
|
|
recovery_hint="Retry the unchanged log read after the bounded backoff.",
|
|
)
|
|
if response.status_code >= 400:
|
|
raise GiteaError("GITEA_REQUEST_FAILED", "Gitea returned an unsuccessful response")
|
|
truncated = len(response.content) > max_bytes
|
|
return response.content[:max_bytes].decode("utf-8", errors="replace"), truncated
|
|
|
|
def create_branch(
|
|
self, repository: RegisteredRepository, branch: str, from_ref: str
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "branches"),
|
|
method="POST",
|
|
json_body={"new_branch_name": branch, "old_ref_name": from_ref},
|
|
),
|
|
)
|
|
|
|
def branch(self, repository: RegisteredRepository, branch: str) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "branches", quote(branch, safe="")
|
|
)
|
|
),
|
|
)
|
|
|
|
def delete_branch(self, repository: RegisteredRepository, branch: str) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "branches", quote(branch, safe="")
|
|
),
|
|
method="DELETE",
|
|
),
|
|
)
|
|
|
|
def create_issue(
|
|
self, repository: RegisteredRepository, title: str, body: str
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "issues"),
|
|
method="POST",
|
|
json_body={"title": title, "body": body},
|
|
),
|
|
)
|
|
|
|
def issue(self, repository: RegisteredRepository, index: int) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "issues", str(index))
|
|
),
|
|
)
|
|
|
|
def update_issue(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
index: int,
|
|
*,
|
|
title: str | None = None,
|
|
body: str | None = None,
|
|
state: str | None = None,
|
|
) -> dict[str, Any]:
|
|
changes: dict[str, Any] = {}
|
|
if title is not None:
|
|
changes["title"] = title
|
|
if body is not None:
|
|
changes["body"] = body
|
|
if state is not None:
|
|
changes["state"] = state
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "issues", str(index)),
|
|
method="PATCH",
|
|
json_body=changes,
|
|
),
|
|
)
|
|
|
|
def create_issue_comment(
|
|
self, repository: RegisteredRepository, index: int, body: str
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path(
|
|
"repos", repository.owner, repository.name, "issues", str(index), "comments"
|
|
),
|
|
method="POST",
|
|
json_body={"body": body},
|
|
),
|
|
)
|
|
|
|
def create_file(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
path: tuple[str, ...],
|
|
*,
|
|
branch: str,
|
|
content: str,
|
|
message: str,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "contents", *path),
|
|
method="POST",
|
|
json_body={
|
|
"branch": branch,
|
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
"message": message,
|
|
},
|
|
),
|
|
)
|
|
|
|
def update_file(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
path: tuple[str, ...],
|
|
*,
|
|
branch: str,
|
|
content: str,
|
|
message: str,
|
|
sha: str,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "contents", *path),
|
|
method="PUT",
|
|
json_body={
|
|
"branch": branch,
|
|
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
"message": message,
|
|
"sha": sha,
|
|
},
|
|
),
|
|
)
|
|
|
|
def delete_file(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
path: tuple[str, ...],
|
|
*,
|
|
branch: str,
|
|
message: str,
|
|
sha: str,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "contents", *path),
|
|
method="DELETE",
|
|
json_body={"branch": branch, "message": message, "sha": sha},
|
|
),
|
|
)
|
|
|
|
def create_pull_request(
|
|
self,
|
|
repository: RegisteredRepository,
|
|
*,
|
|
head: str,
|
|
base: str,
|
|
title: str,
|
|
body: str,
|
|
) -> dict[str, Any]:
|
|
return cast(
|
|
dict[str, Any],
|
|
self._request(
|
|
self._path("repos", repository.owner, repository.name, "pulls"),
|
|
method="POST",
|
|
json_body={"head": head, "base": base, "title": title, "body": body},
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def _as_list(value: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "Gitea returned an unexpected collection")
|
|
return value
|
|
|
|
|
|
def decode_file_payload(payload: Mapping[str, Any], max_bytes: int) -> tuple[str, bool, str]:
|
|
encoded = payload.get("content")
|
|
if not isinstance(encoded, str):
|
|
raise GiteaError("GITEA_INVALID_RESPONSE", "Gitea file response has no content")
|
|
try:
|
|
raw = base64.b64decode(encoded.encode("ascii"), validate=False)
|
|
except (ValueError, UnicodeEncodeError) as exc:
|
|
raise GiteaError(
|
|
"GITEA_INVALID_RESPONSE", "Gitea file content is not valid base64"
|
|
) from exc
|
|
truncated = len(raw) > max_bytes
|
|
return raw[:max_bytes].decode("utf-8", errors="replace"), truncated, str(payload.get("sha", ""))
|