360 lines
13 KiB
Python
360 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
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:
|
|
return str(release.get("tag_name") or "")
|
|
|
|
|
|
def _release_assets(release: dict[str, Any]) -> list[dict[str, Any]]:
|
|
assets = release.get("assets") or []
|
|
return [asset for asset in assets if isinstance(asset, dict)]
|
|
|
|
|
|
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:
|
|
return (0, (), tag)
|
|
return (1, tuple(int(part) for part in match.group(1).split(".")), tag)
|
|
|
|
|
|
def _sorted_releases(releases: list[dict[str, Any]], include_prerelease: bool) -> list[dict[str, Any]]:
|
|
candidates = [
|
|
release
|
|
for release in releases
|
|
if not release.get("draft") and (include_prerelease or not release.get("prerelease"))
|
|
]
|
|
return sorted(
|
|
candidates,
|
|
key=lambda release: (
|
|
str(release.get("published_at") or release.get("created_at") or ""),
|
|
_semver_key(_release_tag(release)),
|
|
),
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
def _matching_assets(
|
|
release: dict[str, Any],
|
|
*,
|
|
asset_patterns: tuple[str, ...],
|
|
current_asset: str | None,
|
|
beat_saber_version: str,
|
|
) -> list[dict[str, Any]]:
|
|
assets = _release_assets(release)
|
|
if asset_patterns:
|
|
assets = [
|
|
asset
|
|
for asset in assets
|
|
if any(fnmatch.fnmatch(_asset_name(asset), pattern) for pattern in asset_patterns)
|
|
]
|
|
if not assets:
|
|
return []
|
|
|
|
exact_version = [asset for asset in assets if _asset_name(asset) == f"{beat_saber_version}.zip"]
|
|
if exact_version:
|
|
return exact_version
|
|
if current_asset:
|
|
same_name = [asset for asset in assets if _asset_name(asset) == current_asset]
|
|
if same_name:
|
|
return same_name
|
|
contains_version = [asset for asset in assets if beat_saber_version in _asset_name(asset)]
|
|
return contains_version or assets
|
|
|
|
|
|
def _find_latest_matching_release(
|
|
releases: list[dict[str, Any]],
|
|
*,
|
|
asset_patterns: tuple[str, ...],
|
|
current_asset: str | None,
|
|
beat_saber_version: str,
|
|
include_prerelease: bool,
|
|
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
|
for release in _sorted_releases(releases, include_prerelease):
|
|
assets = _matching_assets(
|
|
release,
|
|
asset_patterns=asset_patterns,
|
|
current_asset=current_asset,
|
|
beat_saber_version=beat_saber_version,
|
|
)
|
|
if assets:
|
|
return release, assets[0]
|
|
return None
|
|
|
|
|
|
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, "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,
|
|
"repo": repo,
|
|
"currentTag": locked.tag,
|
|
"currentAsset": locked.asset,
|
|
"currentSha256": locked.sha256,
|
|
"latestTag": None,
|
|
"latestAsset": None,
|
|
"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)
|
|
except Exception as exc:
|
|
entry["status"] = "error"
|
|
entry["messages"].append(str(exc))
|
|
summary["errors"] += 1
|
|
plugins.append(entry)
|
|
continue
|
|
|
|
match = _find_latest_matching_release(
|
|
releases,
|
|
asset_patterns=registry_plugin.asset_patterns if registry_plugin else (),
|
|
current_asset=locked.asset,
|
|
beat_saber_version=lockfile.beat_saber_version,
|
|
include_prerelease=include_prerelease,
|
|
)
|
|
if not match:
|
|
entry["status"] = "warning"
|
|
entry["messages"].append("no matching release asset found")
|
|
summary["warnings"] += 1
|
|
plugins.append(entry)
|
|
continue
|
|
|
|
latest_release, latest_asset = match
|
|
entry["latestTag"] = _release_tag(latest_release)
|
|
entry["latestAsset"] = _asset_name(latest_asset)
|
|
entry["latestAssetSha256"] = str(latest_asset.get("digest") or "").removeprefix("sha256:") or None
|
|
entry["latestUrl"] = latest_release.get("html_url")
|
|
entry["latestAssetUrl"] = latest_asset.get("browser_download_url")
|
|
same_release = locked.tag == entry["latestTag"] and locked.asset == entry["latestAsset"]
|
|
same_hash = not entry["latestAssetSha256"] or locked.sha256 == entry["latestAssetSha256"]
|
|
if same_release and same_hash:
|
|
entry["status"] = "current"
|
|
summary["current"] += 1
|
|
else:
|
|
entry["status"] = "update"
|
|
summary["updates"] += 1
|
|
plugins.append(entry)
|
|
|
|
return {
|
|
"instance": lockfile.instance,
|
|
"beatSaberVersion": lockfile.beat_saber_version,
|
|
"includePrerelease": include_prerelease,
|
|
"summary": summary,
|
|
"plugins": plugins,
|
|
}
|