Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""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"),
|
||||
)
|
||||
Reference in New Issue
Block a user