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:
@@ -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
|
||||
Reference in New Issue
Block a user