"""GitHub Releases. Covers the open-source half of the fleet: OBS, Bitfocus Companion and Satellite, RustDesk. Unauthenticated GitHub allows 60 requests/hour per IP, which is enough for a handful of items but not for issue scanning as well -- set AVWATCH_GITHUB_TOKEN (a classic token with no scopes is fine) to lift the ceiling to 5000/hour. Inventory usage: source: kind: github repo: obsproject/obs-studio allow_prerelease: false tag_pattern: '^(?:v)?([\\d.]+)$' # optional override """ from __future__ import annotations import os import re from datetime import datetime, timezone from ..models import Item, VersionCheck from ..versions import parse from .base import Context, error API = "https://api.github.com" DEFAULT_TAG_PATTERN = r"^(?:v|release-|Release\s*)?([0-9]+(?:\.[0-9]+)*)" def auth_headers() -> dict: headers = { "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } token = os.environ.get("AVWATCH_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN") if token: headers["Authorization"] = f"Bearer {token}" return headers def _parse_ts(text: str | None) -> datetime | None: if not text: return None try: return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) except ValueError: return None class GithubSource: kind = "github" def check(self, item: Item, ctx: Context) -> VersionCheck: repo = item.source.get("repo") or item.github_repo if not repo: return error(item, "github source requires 'repo' (owner/name)") allow_pre = bool(item.source.get("allow_prerelease", False)) rx = re.compile(item.source.get("tag_pattern") or DEFAULT_TAG_PATTERN) # /releases (plural) so we can skip drafts and prereleases ourselves; # /releases/latest already excludes prereleases but also hides the # release notes of anything newer that is still flagged pre. resp = ctx.http.get( f"{API}/repos/{repo}/releases?per_page=20", headers=auth_headers(), ttl=3600, ) if not resp.ok: if resp.status == 403: return error(item, "GitHub rate limit hit; set AVWATCH_GITHUB_TOKEN") if resp.status == 404: return error(item, f"repo not found: {repo}") return error(item, f"HTTP {resp.status} from GitHub for {repo}") try: releases = resp.json() except ValueError as exc: return error(item, f"bad JSON from GitHub: {exc}") if not isinstance(releases, list) or not releases: return error(item, f"no releases published for {repo}") best: tuple | None = None for rel in releases: if rel.get("draft"): continue if rel.get("prerelease") and not allow_pre: continue tag = (rel.get("tag_name") or "").strip() m = rx.match(tag) candidate = m.group(1) if m else tag ver = parse(candidate) if not ver.valid: continue if best is None or ver > best[0]: best = (ver, rel) if best is None: return error(item, f"no parseable release tags for {repo}") ver, rel = best body = (rel.get("body") or "").strip() if len(body) > 4000: body = body[:4000] + "\n...[truncated]" return VersionCheck( item_id=item.id, latest=ver.raw, released=_parse_ts(rel.get("published_at") or rel.get("created_at")), status="ok", release_notes=body or None, release_url=rel.get("html_url"), detail=rel.get("name") or rel.get("tag_name"), )