feat: deferred dispatch, release-check provider, settings polish

- Defer quiet-hours dispatches into new deferred_dispatch table; drain
  job + periodic catch-up scan re-fire at window end with coalescing on
  (link, event_type, collection_id).
- Add ON DELETE SET NULL migration on event_log_id and partial unique
  index on (link_id, collection_id, event_type) WHERE status='pending'.
- Add release-check provider abstraction (Gitea/GitHub) with SSRF-safe
  URL validation, settings UI cassette, and scheduled polling.
- Replace importlib-only version lookup with version.py helper that
  prefers the higher of installed metadata vs source pyproject so stale
  editable dev installs stop misreporting.
- Aurora frontend polish: MetaStrip component, ReleaseCassette,
  EventDetailModal expansion, and i18n additions.
This commit is contained in:
2026-05-12 02:58:07 +03:00
parent bb5afcc222
commit ba199f24bd
47 changed files with 5627 additions and 290 deletions
@@ -0,0 +1,32 @@
"""Upstream release-check providers.
This package is intentionally separate from :mod:`notify_bridge_core.providers`:
* service providers are user-configured entities persisted per-tenant in the DB;
* release providers are admin-level upstream-version probes selected by setting,
with at most one active provider per installation.
Mixing them in one enum/factory bled responsibilities and complicated future
additions (e.g. a GitHub release provider that has nothing to do with Gitea
service integrations).
"""
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProvider,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
)
from .registry import build_release_provider
__all__ = [
"ReleaseErrorCode",
"ReleaseInfo",
"ReleaseProvider",
"ReleaseProviderKind",
"ReleaseTestResult",
"build_release_provider",
"is_valid_repo",
]
@@ -0,0 +1,156 @@
"""ReleaseProvider abstraction and shared tag/version utilities."""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
from typing import ClassVar, Protocol, TypedDict, runtime_checkable
class ReleaseProviderKind(str, Enum):
"""Supported upstream release-check providers."""
DISABLED = "disabled"
GITEA = "gitea"
GITHUB = "github"
# Single source of truth for `release_error` taxonomy. Surfaced into the cached
# `AppSetting`, returned via the API, and translated by the frontend.
class ReleaseErrorCode(str, Enum):
DISABLED = "disabled"
MISCONFIGURED = "misconfigured"
PROVIDER_CHANGED = "provider_changed"
NO_RELEASE_FOUND = "no_release_found"
NETWORK_ERROR = "network_error"
HTTP_ERROR = "http_error"
PARSE_ERROR = "parse_error"
UNSAFE_URL = "unsafe_url"
NOT_IMPLEMENTED = "not_implemented"
UNKNOWN_ERROR = "unknown_error"
@dataclass(frozen=True)
class ReleaseInfo:
"""Normalised release metadata returned by a provider."""
tag: str
version: str
name: str | None = None
body: str | None = None
url: str | None = None
published_at: str | None = None
prerelease: bool = False
draft: bool = False
class ReleaseTestResult(TypedDict):
"""Structured shape returned by :meth:`ReleaseProvider.test`."""
ok: bool
info: ReleaseInfo | None
error: str | None
@runtime_checkable
class ReleaseProvider(Protocol):
"""Protocol implemented by every release provider.
Implementations are expected to be safe to instantiate without external
side effects — connectivity is deferred until :meth:`fetch_latest` or
:meth:`test` is awaited.
"""
kind: ClassVar[ReleaseProviderKind]
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
"""Return the latest release, or ``None`` if there is nothing to report."""
async def test(self) -> ReleaseTestResult:
"""Probe the upstream and return a structured status payload."""
# Owner/name validation — matches Gitea/GitHub's allowed identifier chars.
_REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
def is_valid_repo(repo: str) -> bool:
"""``True`` when ``repo`` is a safe ``owner/name`` string (no path traversal)."""
return bool(repo) and _REPO_RE.match(repo) is not None
_TAG_NUMERIC = re.compile(r"\d+")
# Stop reading numeric segments at the first non-digit-non-dot character so
# ``1.0a2`` doesn't get parsed as ``(1, 0, 2)``.
_HEAD_SPLIT = re.compile(r"[^0-9.]")
def normalise_version(tag: str) -> str:
"""Strip a leading ``v`` from a tag (``"v1.2.3"`` → ``"1.2.3"``)."""
if not tag:
return ""
cleaned = tag.strip()
if cleaned.startswith(("v", "V")) and len(cleaned) > 1 and cleaned[1].isdigit():
cleaned = cleaned[1:]
return cleaned
def _split_version(version: str) -> tuple[tuple[int, ...], str]:
"""Split a version into (numeric segments, prerelease suffix).
A non-empty prerelease suffix marks the version as pre-stable. We use it
as a tie-break only — when numeric segments are equal a stable build
sorts strictly newer than its pre-release counterpart (``0.7.2`` >
``0.7.2-rc1``), which prevents the badge from flickering between
"up to date" and "downgrade available" on installs that ship the GA.
"""
if not version:
return (), ""
work = version.split("+", 1)[0]
if "-" in work:
head, _, suffix = work.partition("-")
else:
# Implicit prerelease form: ``1.0a2`` / ``1.0rc1``. Anything after the
# first non-digit-non-dot is treated as the suffix.
m = _HEAD_SPLIT.search(work)
if m and m.start() > 0:
head, suffix = work[: m.start()], work[m.start():]
else:
head, suffix = work, ""
segments = tuple(int(n) for n in _TAG_NUMERIC.findall(head))
return segments, suffix.strip()
def compare_versions(a: str, b: str) -> int:
"""Return ``1`` if ``a > b``, ``-1`` if ``a < b``, ``0`` if equal.
Numeric segments win. When numerically equal, *stable* (no suffix) beats
*prerelease* (any non-empty suffix); two equally-prereleased versions
compare equal — we deliberately do not order ``rc2`` over ``rc1`` because
that requires real semver parsing and would only matter for downgrades.
"""
sa, suffix_a = _split_version(normalise_version(a))
sb, suffix_b = _split_version(normalise_version(b))
length = max(len(sa), len(sb))
for i in range(length):
x = sa[i] if i < len(sa) else 0
y = sb[i] if i < len(sb) else 0
if x != y:
return 1 if x > y else -1
# Equal numerics — stable beats prerelease.
if not suffix_a and suffix_b:
return 1
if suffix_a and not suffix_b:
return -1
return 0
def is_newer(candidate: str, baseline: str) -> bool:
"""``True`` when ``candidate`` is strictly newer than ``baseline``."""
return compare_versions(candidate, baseline) > 0
@@ -0,0 +1,167 @@
"""Gitea release provider — queries ``/api/v1/repos/{owner}/{repo}/releases``."""
from __future__ import annotations
import asyncio
import logging
from typing import ClassVar
import aiohttp
from ..notifications.ssrf import UnsafeURLError, avalidate_outbound_url
from .base import (
ReleaseErrorCode,
ReleaseInfo,
ReleaseProviderKind,
ReleaseTestResult,
is_valid_repo,
normalise_version,
)
_LOGGER = logging.getLogger(__name__)
# Cap upstream response body — release lists are normally a few KB; anything
# beyond this is either a misconfigured target or a malicious payload.
_MAX_BODY_BYTES = 1_000_000
class GiteaReleaseProvider:
"""Anonymous Gitea release probe.
Hits the ``releases`` endpoint (not ``releases/latest``) because the latter
skips pre-releases unconditionally — we want to honour the caller's
``include_prereleases`` flag instead of relying on Gitea's filtering.
"""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITEA
def __init__(self, session: aiohttp.ClientSession, url: str, repo: str) -> None:
if not url:
raise ValueError("Gitea release provider requires a base URL")
if not is_valid_repo(repo):
raise ValueError(
"Gitea release provider requires repo as 'owner/name' "
"(alphanumerics, dot, dash, underscore only)"
)
self._session = session
self._url = url.rstrip("/")
self._repo = repo.strip("/")
@property
def _endpoint(self) -> str:
return f"{self._url}/api/v1/repos/{self._repo}/releases"
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError as err:
_LOGGER.warning("Gitea release URL rejected by SSRF guard: %s", err)
return None
try:
async with self._session.get(
self._endpoint,
params={"limit": "20", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
_LOGGER.warning(
"Gitea releases fetch failed: HTTP %s for %s",
response.status, self._endpoint,
)
return None
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response exceeded %d bytes — refusing to parse",
_MAX_BODY_BYTES,
)
return None
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError) as err:
_LOGGER.warning("Gitea releases fetch error: %s", err)
return None
except (ValueError, UnicodeDecodeError) as err:
_LOGGER.warning("Gitea releases parse error: %s", err)
return None
if not isinstance(payload, list):
return None
for entry in payload:
if not isinstance(entry, dict):
continue
if entry.get("draft"):
continue
if entry.get("prerelease") and not include_prereleases:
continue
return _to_release_info(entry)
return None
async def test(self) -> ReleaseTestResult:
# Validate URL first so the "test" button surfaces an SSRF rejection
# to the operator rather than silently returning "unreachable".
try:
await avalidate_outbound_url(self._endpoint)
except UnsafeURLError:
return {"ok": False, "info": None, "error": ReleaseErrorCode.UNSAFE_URL.value}
try:
async with self._session.get(
self._endpoint,
params={"limit": "1", "page": "1", "draft": "false"},
) as response:
if response.status != 200:
return {"ok": False, "info": None, "error": ReleaseErrorCode.HTTP_ERROR.value}
# Enforce a size cap without trusting chunked encoding: read
# the whole body (aiohttp buffers it) but reject anything that
# advertised more than the cap up front, and bail if it grew
# past the cap after the fact.
if response.content_length is not None and response.content_length > _MAX_BODY_BYTES:
_LOGGER.warning(
"Gitea releases response advertised %d bytes — refusing",
response.content_length,
)
return None
raw = await response.read()
if len(raw) > _MAX_BODY_BYTES:
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
import json
payload = json.loads(raw.decode("utf-8"))
except (aiohttp.ClientError, asyncio.TimeoutError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.NETWORK_ERROR.value}
except (ValueError, UnicodeDecodeError):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
if not isinstance(payload, list) or not payload:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NO_RELEASE_FOUND.value}
first = payload[0]
if not isinstance(first, dict):
return {"ok": False, "info": None, "error": ReleaseErrorCode.PARSE_ERROR.value}
return {"ok": True, "info": _to_release_info(first), "error": None}
def _to_release_info(entry: dict) -> ReleaseInfo:
tag = str(entry.get("tag_name") or "").strip()
return ReleaseInfo(
tag=tag,
version=normalise_version(tag),
name=entry.get("name") or None,
body=entry.get("body") or None,
url=entry.get("html_url") or None,
published_at=entry.get("published_at") or entry.get("created_at") or None,
prerelease=bool(entry.get("prerelease", False)),
draft=bool(entry.get("draft", False)),
)
@@ -0,0 +1,34 @@
"""GitHub release provider stub.
Reserved so the registry advertises the option and the frontend can render the
provider toggle without a follow-up backend release. The full implementation
will mirror :class:`GiteaReleaseProvider` against
``api.github.com/repos/{owner}/{repo}/releases``.
"""
from __future__ import annotations
from typing import ClassVar
import aiohttp
from .base import ReleaseErrorCode, ReleaseInfo, ReleaseProviderKind, ReleaseTestResult
class GitHubReleaseProvider:
"""Not yet implemented — placeholder so the registry is forward-compatible."""
kind: ClassVar[ReleaseProviderKind] = ReleaseProviderKind.GITHUB
def __init__(self, session: aiohttp.ClientSession, repo: str) -> None:
self._session = session
self._repo = repo
async def fetch_latest(self, *, include_prereleases: bool = False) -> ReleaseInfo | None:
# Soft-fail rather than raise — `run_check` already catches
# NotImplementedError but a None return keeps the persisted
# `release_error` taxonomy clean (NOT_IMPLEMENTED, not "not impl…").
return None
async def test(self) -> ReleaseTestResult:
return {"ok": False, "info": None, "error": ReleaseErrorCode.NOT_IMPLEMENTED.value}
@@ -0,0 +1,51 @@
"""Factory for release providers — single entry point for callers."""
from __future__ import annotations
from typing import TYPE_CHECKING
from .base import ReleaseProvider, ReleaseProviderKind, is_valid_repo
from .gitea import GiteaReleaseProvider
from .github import GitHubReleaseProvider
if TYPE_CHECKING:
import aiohttp
def build_release_provider(
kind: str | ReleaseProviderKind,
*,
session: aiohttp.ClientSession,
url: str = "",
repo: str = "",
) -> ReleaseProvider | None:
"""Build a release provider for the given kind.
Returns ``None`` when disabled or when required configuration is missing
or unsafe (invalid repo format, empty URL) — callers treat the absence as
"no checks performed" without branching on the kind string everywhere.
"""
try:
normalised = (
ReleaseProviderKind(kind)
if not isinstance(kind, ReleaseProviderKind)
else kind
)
except ValueError:
return None
if normalised is ReleaseProviderKind.DISABLED:
return None
if normalised is ReleaseProviderKind.GITEA:
if not url or not is_valid_repo(repo):
return None
try:
return GiteaReleaseProvider(session=session, url=url, repo=repo)
except ValueError:
return None
if normalised is ReleaseProviderKind.GITHUB:
if not is_valid_repo(repo):
return None
return GitHubReleaseProvider(session=session, repo=repo)
return None