117 lines
3.8 KiB
Python
117 lines
3.8 KiB
Python
"""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"),
|
|
)
|