Initial commit: AVWatch v1.0.0 with .gitignore and CLAUDE.md

This commit is contained in:
2026-09-24 13:07:21 -04:00
commit ea70a9cb5f
38 changed files with 9604 additions and 0 deletions
+116
View File
@@ -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"),
)