Add Beat Saber plugin update audit skill
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -49,6 +52,24 @@ def normalize_mods(payload: Any) -> list[BeatModsEntry]:
|
||||
return [normalize_entry(entry) for entry in extract_mods(payload)]
|
||||
|
||||
|
||||
def fetch_verified_mods(game_version: str) -> list[dict[str, Any]]:
|
||||
query = urlencode(
|
||||
{
|
||||
"status": "verified",
|
||||
"gameVersion": game_version,
|
||||
"gameName": "BeatSaber",
|
||||
"platform": "steampc",
|
||||
}
|
||||
)
|
||||
request = Request(
|
||||
f"https://beatmods.com/api/mods?{query}",
|
||||
headers={"User-Agent": "Mozilla/5.0 plugin-helper"},
|
||||
)
|
||||
with urlopen(request, timeout=20) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
return extract_mods(data)
|
||||
|
||||
|
||||
def by_version_id(entries: list[BeatModsEntry]) -> dict[int, BeatModsEntry]:
|
||||
return {entry.version_id: entry for entry in entries if entry.version_id is not None}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import repo_root, resolve_runtime_config
|
||||
from .beatmods import fetch_verified_mods
|
||||
from .bootstrap import run_bootstrap
|
||||
from .bsipa import check_bsipa_health
|
||||
from .checker import check_lock
|
||||
@@ -33,12 +34,13 @@ def print_updates(report: dict[str, Any]) -> None:
|
||||
print(
|
||||
f"{report['instance']} updates: "
|
||||
f"{summary['updates']} available, {summary['current']} current, "
|
||||
f"{summary.get('reviews', 0)} review, {summary.get('skipped', 0)} skipped, "
|
||||
f"{summary['warnings']} warnings, {summary['errors']} errors"
|
||||
)
|
||||
if not plugins:
|
||||
return
|
||||
|
||||
headers = ("Plugin", "Current", "Latest", "Asset", "Status")
|
||||
headers = ("Plugin", "Current", "Latest", "Asset", "Status", "Notes")
|
||||
rows = [
|
||||
(
|
||||
f"{plugin['name']} ({plugin['id']})",
|
||||
@@ -46,6 +48,7 @@ def print_updates(report: dict[str, Any]) -> None:
|
||||
plugin.get("latestTag") or "(unknown)",
|
||||
plugin.get("latestAsset") or plugin.get("currentAsset") or "(unknown)",
|
||||
plugin["status"],
|
||||
", ".join(plugin.get("reviewReasons") or plugin.get("messages") or ()),
|
||||
)
|
||||
for plugin in plugins
|
||||
]
|
||||
@@ -142,7 +145,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
updates = subcommands.add_parser(
|
||||
"updates",
|
||||
help="Check GitHub for newer matching releases for locked plugins",
|
||||
help="Check GitHub and BeatMods for newer matching releases for locked plugins",
|
||||
parents=[_common_parent()],
|
||||
)
|
||||
updates.add_argument("--instance", required=True)
|
||||
@@ -406,6 +409,7 @@ def run(argv: list[str] | None = None) -> int:
|
||||
registry=load_registry(registry_path),
|
||||
lockfile=load_lockfile(lock_path),
|
||||
fetch_releases=fetch_releases,
|
||||
fetch_beatmods=fetch_verified_mods,
|
||||
selected=set(args.plugin) if args.plugin else None,
|
||||
include_prerelease=args.include_prerelease,
|
||||
)
|
||||
|
||||
@@ -4,10 +4,28 @@ import fnmatch
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from .beatmods import BeatModsEntry, normalize_mods
|
||||
from .models import Lockfile, Registry
|
||||
|
||||
|
||||
FetchReleases = Callable[[str], list[dict[str, Any]]]
|
||||
FetchBeatMods = Callable[[str], list[dict[str, Any]]]
|
||||
|
||||
PRIVATE_SOURCE_PREFIXES = ("discord/", "patreon/")
|
||||
REVIEW_PATTERNS = (
|
||||
"local build",
|
||||
"localbuild",
|
||||
"github pr",
|
||||
"/pull/",
|
||||
" pr ",
|
||||
"manual",
|
||||
"compatibility trial",
|
||||
"failed compatibility",
|
||||
"currently failed",
|
||||
"smoke blocked",
|
||||
"resmoke pending",
|
||||
"do not reinstall",
|
||||
)
|
||||
|
||||
|
||||
def _release_tag(release: dict[str, Any]) -> str:
|
||||
@@ -23,6 +41,71 @@ def _asset_name(asset: dict[str, Any]) -> str:
|
||||
return str(asset.get("name") or "")
|
||||
|
||||
|
||||
def _source_kind(repo: str | None) -> str:
|
||||
if not repo:
|
||||
return "missing"
|
||||
lowered = repo.lower()
|
||||
if lowered.startswith(PRIVATE_SOURCE_PREFIXES):
|
||||
return "private"
|
||||
if re.fullmatch(r"[^/\s]+/[^/\s]+", repo):
|
||||
return "github"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _is_beatmods_locked(tag: str | None, reason: str | None) -> bool:
|
||||
return str(tag or "").startswith("beatmods-") or "beatmods" in str(reason or "").lower()
|
||||
|
||||
|
||||
def _current_beatmods_version(tag: str | None, reason: str | None, asset: str | None) -> str | None:
|
||||
if tag and tag.startswith("beatmods-"):
|
||||
return tag.removeprefix("beatmods-")
|
||||
for text in (reason, asset):
|
||||
if not text:
|
||||
continue
|
||||
match = re.search(r"\bBeatMods(?:\s+verified)?\s+[^0-9]*(\d+(?:\.\d+){1,4})\b", text, re.I)
|
||||
if match:
|
||||
return match.group(1)
|
||||
match = re.search(r"-(\d+(?:\.\d+){1,4})(?:\.zip|\+|-|$)", text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _normalized_name(text: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "", text.lower())
|
||||
|
||||
|
||||
def _beatmods_match(
|
||||
entries: list[BeatModsEntry],
|
||||
*,
|
||||
plugin_id: str,
|
||||
plugin_name: str,
|
||||
) -> BeatModsEntry | None:
|
||||
wanted = {_normalized_name(plugin_id), _normalized_name(plugin_name)}
|
||||
for entry in entries:
|
||||
if _normalized_name(entry.name) in wanted:
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def _review_reasons(tag: str | None, reason: str | None) -> list[str]:
|
||||
text = f"{tag or ''} {reason or ''}".lower()
|
||||
reasons: list[str] = []
|
||||
if any(pattern in text for pattern in ("local build", "localbuild")):
|
||||
reasons.append("local build")
|
||||
if any(pattern in text for pattern in ("github pr", "/pull/", " pr ")) or str(tag or "").startswith("pr-"):
|
||||
reasons.append("pull request build")
|
||||
if "failed" in text or "compatibility trial" in text:
|
||||
reasons.append("compatibility failure history")
|
||||
if "smoke blocked" in text or "resmoke pending" in text or "currently failed" in text:
|
||||
reasons.append("verification follow-up pending")
|
||||
if "manual" in text:
|
||||
reasons.append("manual install notes")
|
||||
if not reasons and any(pattern in text for pattern in REVIEW_PATTERNS):
|
||||
reasons.append("special handling noted")
|
||||
return reasons
|
||||
|
||||
|
||||
def _semver_key(tag: str) -> tuple[int, tuple[int, ...], str]:
|
||||
match = re.search(r"(\d+(?:\.\d+){0,3})", tag)
|
||||
if not match:
|
||||
@@ -99,18 +182,22 @@ def check_updates(
|
||||
registry: Registry,
|
||||
lockfile: Lockfile,
|
||||
fetch_releases: FetchReleases,
|
||||
fetch_beatmods: FetchBeatMods | None = None,
|
||||
selected: set[str] | None = None,
|
||||
include_prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
selected_ids = selected or {plugin.id for plugin in lockfile.plugins}
|
||||
plugins: list[dict[str, Any]] = []
|
||||
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0}
|
||||
summary = {"current": 0, "updates": 0, "warnings": 0, "errors": 0, "skipped": 0, "reviews": 0}
|
||||
beatmods_entries: list[BeatModsEntry] | None = None
|
||||
beatmods_error: Exception | None = None
|
||||
|
||||
for locked in lockfile.plugins:
|
||||
if locked.id not in selected_ids:
|
||||
continue
|
||||
registry_plugin = registry.get(locked.id)
|
||||
repo = locked.repo or (registry_plugin.repo if registry_plugin else None)
|
||||
review_reasons = _review_reasons(locked.tag, locked.reason)
|
||||
entry: dict[str, Any] = {
|
||||
"id": locked.id,
|
||||
"name": registry_plugin.name if registry_plugin else locked.id,
|
||||
@@ -123,13 +210,106 @@ def check_updates(
|
||||
"latestAssetSha256": None,
|
||||
"status": "unknown",
|
||||
"messages": [],
|
||||
"review": bool(review_reasons),
|
||||
"reviewReasons": review_reasons,
|
||||
}
|
||||
if review_reasons:
|
||||
summary["reviews"] += 1
|
||||
|
||||
source_kind = _source_kind(repo)
|
||||
if source_kind == "private":
|
||||
entry["status"] = "skipped"
|
||||
entry["messages"].append("paid/private source; check manually outside public update APIs")
|
||||
summary["skipped"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
needs_manual_review = any(
|
||||
reason in review_reasons
|
||||
for reason in (
|
||||
"local build",
|
||||
"pull request build",
|
||||
"verification follow-up pending",
|
||||
"manual install notes",
|
||||
)
|
||||
)
|
||||
if review_reasons and (needs_manual_review or not _is_beatmods_locked(locked.tag, locked.reason)):
|
||||
entry["status"] = "review"
|
||||
entry["messages"].append("special install or compatibility history; review notes before updating")
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
if _is_beatmods_locked(locked.tag, locked.reason):
|
||||
if fetch_beatmods is None:
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("BeatMods check unavailable")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
if beatmods_entries is None and beatmods_error is None:
|
||||
try:
|
||||
beatmods_entries = normalize_mods(fetch_beatmods(lockfile.beat_saber_version))
|
||||
except Exception as exc:
|
||||
beatmods_error = exc
|
||||
if beatmods_error is not None:
|
||||
entry["status"] = "error"
|
||||
entry["messages"].append(f"BeatMods: {beatmods_error}")
|
||||
summary["errors"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
beatmods_match = _beatmods_match(
|
||||
beatmods_entries or [],
|
||||
plugin_id=locked.id,
|
||||
plugin_name=entry["name"],
|
||||
)
|
||||
if beatmods_match is not None and beatmods_match.mod_version:
|
||||
current_version = _current_beatmods_version(locked.tag, locked.reason, locked.asset)
|
||||
entry["latestTag"] = f"beatmods-{beatmods_match.mod_version}"
|
||||
entry["latestAsset"] = (
|
||||
f"{beatmods_match.name}-{beatmods_match.mod_version}.zip"
|
||||
if beatmods_match.name
|
||||
else None
|
||||
)
|
||||
entry["latestBeatModsVersionId"] = beatmods_match.version_id
|
||||
entry["latestBeatModsZipHash"] = beatmods_match.zip_hash
|
||||
entry["latestAssetUrl"] = (
|
||||
f"https://beatmods.com/cdn/mod/{beatmods_match.zip_hash}.zip"
|
||||
if beatmods_match.zip_hash
|
||||
else None
|
||||
)
|
||||
if current_version == beatmods_match.mod_version:
|
||||
entry["status"] = "current"
|
||||
summary["current"] += 1
|
||||
else:
|
||||
entry["status"] = "update"
|
||||
entry["messages"].append(
|
||||
f"BeatMods verified {beatmods_match.mod_version} for Beat Saber {lockfile.beat_saber_version}"
|
||||
)
|
||||
summary["updates"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
if source_kind != "github":
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("no matching BeatMods verified entry found")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
entry["messages"].append("no matching BeatMods verified entry found; falling back to GitHub")
|
||||
|
||||
if not repo:
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("missing repository")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
if source_kind != "github":
|
||||
entry["status"] = "warning"
|
||||
entry["messages"].append("unsupported repository source")
|
||||
summary["warnings"] += 1
|
||||
plugins.append(entry)
|
||||
continue
|
||||
|
||||
try:
|
||||
releases = fetch_releases(repo)
|
||||
|
||||
Reference in New Issue
Block a user