Files
ITWorx_MCP_Hub-Public/packages/connector_kit/gitea.py
T

1433 lines
53 KiB
Python

"""Typed, bounded Gitea client primitives for registered repositories."""
from __future__ import annotations
import base64
import copy
import ipaddress
import json
import re
import time
from collections.abc import Mapping
from datetime import UTC, datetime
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]+$")
_semver = re.compile(
r"v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
)
_version_cache_seconds = 30
_supported_ranges = (">=1.14.0 <1.15.0", ">=1.25.0 <1.28.0", ">=28.0.0 <29.0.0")
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._version_lock = RLock()
self._version_cache: tuple[float, dict[str, Any]] | 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 upstream_compatibility(self, *, force_refresh: bool = False) -> dict[str, Any]:
"""Read the upstream version, independent of this connector's build version.
Diagnostics cache for 30 seconds; each merge negotiates again immediately
before its one write. Failed probes replace cached success and never fall
back to the connector version. Gitea 28 drops the historical ``1.`` prefix.
"""
with self._version_lock:
now = time.monotonic()
if (
not force_refresh
and self._version_cache is not None
and now - self._version_cache[0] < self._version_cache_seconds
):
return copy.deepcopy(self._version_cache[1])
diagnostics: dict[str, Any] = {
"source": "/api/v1/version",
"upstreamVersion": None,
"normalizedVersion": None,
"compatibility": "UNKNOWN",
"reason": "upstream_version_missing_or_malformed",
"mergeStrategyField": None,
"supportedRanges": list(self._supported_ranges),
"prereleasesSupported": False,
"checkedAt": datetime.now(UTC).isoformat(),
"cacheSeconds": self._version_cache_seconds,
"errorCode": None,
}
try:
payload = self._request(self._path("version"))
except GiteaError as exc:
diagnostics.update(
compatibility="UNAVAILABLE",
reason="upstream_version_probe_failed",
errorCode=exc.code,
)
else:
version = payload.get("version") if isinstance(payload, dict) else None
# Do not reflect arbitrary upstream data (or secret-like content).
if isinstance(version, str) and len(version) <= 128:
version = version.strip()
match = self._semver.fullmatch(version)
if match is not None:
release = tuple(int(match.group(index)) for index in (1, 2, 3))
prerelease = match.group(4)
invalid_prerelease = prerelease is not None and any(
part.isdigit() and len(part) > 1 and part.startswith("0")
for part in prerelease.split(".")
)
if not invalid_prerelease:
# Arbitrary SemVer suffixes can contain opaque credentials.
# Display only the numeric release; negotiate the full parsed version.
display_version = ("v" if version.startswith("v") else "") + ".".join(
str(part) for part in release
)
diagnostics.update(
upstreamVersion=display_version,
normalizedVersion=display_version.removeprefix("v"),
compatibility="UNSUPPORTED",
reason="upstream_release_outside_tested_ranges",
)
supported = (
(1, 14, 0) <= release < (1, 15, 0)
or (1, 25, 0) <= release < (1, 28, 0)
or (28, 0, 0) <= release < (29, 0, 0)
)
if prerelease is not None:
diagnostics["reason"] = "upstream_prerelease_not_tested"
elif supported:
diagnostics.update(
compatibility="SUPPORTED",
reason="upstream_release_in_tested_ranges",
mergeStrategyField=("Do" if release < (1, 26, 0) else "do"),
)
self._version_cache = (time.monotonic(), copy.deepcopy(diagnostics))
return diagnostics
def _merge_strategy_field(self) -> str:
"""Negotiate against the current upstream immediately before the merge write."""
diagnostics = self.upstream_compatibility(force_refresh=True)
if diagnostics["compatibility"] != "SUPPORTED":
raise GiteaError(
diagnostics["errorCode"] or "GITEA_VERSION_UNSUPPORTED",
(
"Gitea merge compatibility requires an identified "
"1.14, 1.25, 1.26, 1.27, or 28.x stable server; "
f"{diagnostics['reason']}"
),
category="compatibility",
expected_state="Stable upstream release in tested compatibility ranges",
actual_state=str(diagnostics["compatibility"]),
recovery_hint="Inspect gitea.capabilities.get upstreamGitea diagnostics",
)
return str(diagnostics["mergeStrategyField"])
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", ""))