feat: Add AI-driven auto-update & restructure into package

The monolithic check_updates.py has been split into the update_monitor/
package with separate modules for fetching, extraction, comparison,
classification, and notification. This makes the codebase easier to
navigate and test.

The flat services.yaml is replaced by per-service directories under
services/<name>/config.yaml, co-locating config with update scripts
and AI review examples.

When --auto-update is enabled, services with an auto-update.sh script
get their release notes classified by a local Ollama model (using
few-shot examples from review-examples/) to decide whether the update
can be applied automatically or needs manual admin intervention. Results
are reported in a single consolidated Matrix message.

Immich is the first service with auto-update configured, using 7
real release note examples to train the classifier.
This commit is contained in:
Jeena 2026-04-09 12:49:57 +00:00
parent f52ff222f0
commit f55f3d8e91
36 changed files with 2612 additions and 821 deletions

118
update_monitor/loader.py Normal file
View file

@ -0,0 +1,118 @@
import os
from pathlib import Path
from typing import Any, Dict
import yaml
from .models import ExtractRule, ServiceConfig
def load_yaml(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as handle:
return yaml.safe_load(handle) or {}
def load_dotenv(path: str = ".env") -> None:
if not os.path.exists(path):
return
try:
with open(path, "r", encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key:
os.environ.setdefault(key, value)
except OSError:
return
def parse_extract_rule(raw: Any) -> "ExtractRule | None":
if not raw:
return None
rule_type = raw.get("type")
value = raw.get("value")
if not rule_type or not value:
return None
allowed = {"jsonpath", "jsonpath_join", "regex", "text", "header"}
if rule_type not in allowed:
raise ValueError(f"Unsupported extract rule type: {rule_type}")
return ExtractRule(type=rule_type, value=value)
def load_services(config: Dict[str, Any]) -> Dict[str, ServiceConfig]:
services = config.get("services")
if not isinstance(services, list):
raise ValueError("Config must include a 'services' list")
loaded: Dict[str, ServiceConfig] = {}
for entry in services:
if not isinstance(entry, dict):
raise ValueError("Each service entry must be a mapping")
name = entry.get("name")
base_url = entry.get("base_url")
if not name or not base_url:
raise ValueError("Each service must include name and base_url")
if not isinstance(name, str) or not isinstance(base_url, str):
raise ValueError("Service name and base_url must be strings")
current_headers = entry.get("current_version_headers")
upstream_headers = entry.get("upstream_latest_headers")
if current_headers is not None and not isinstance(current_headers, dict):
raise ValueError("current_version_headers must be a mapping")
if upstream_headers is not None and not isinstance(upstream_headers, dict):
raise ValueError("upstream_latest_headers must be a mapping")
current_url = entry.get("current_version_url")
upstream_url = entry.get("upstream_latest_version_url")
current_extract = parse_extract_rule(entry.get("current_version_extract"))
upstream_extract = parse_extract_rule(entry.get("upstream_latest_extract"))
if current_url and not current_extract:
raise ValueError(f"Service {name} must define current_version_extract")
if upstream_url and not upstream_extract:
raise ValueError(f"Service {name} must define upstream_latest_extract")
login = entry.get("login")
if login is not None and not isinstance(login, dict):
raise ValueError("login must be a mapping")
login_socket = entry.get("login_socket")
if login_socket is not None and not isinstance(login_socket, dict):
raise ValueError("login_socket must be a mapping")
loaded[name] = ServiceConfig(
name=name,
base_url=base_url,
current_version_url=current_url,
current_version_extract=current_extract,
current_version_headers=current_headers,
upstream_latest_version_url=upstream_url,
upstream_latest_extract=upstream_extract,
upstream_latest_headers=upstream_headers,
notes=entry.get("notes"),
login=login,
login_socket=login_socket,
)
return loaded
def load_services_dir(path: str) -> Dict[str, ServiceConfig]:
services_path = Path(path)
if not services_path.is_dir():
raise ValueError(f"Services directory not found: {path}")
loaded: Dict[str, ServiceConfig] = {}
for config_file in sorted(services_path.glob("*/config.yaml")):
service_dir = config_file.parent
try:
config = load_yaml(str(config_file))
except Exception as exc:
raise ValueError(f"Failed to load {config_file}: {exc}") from exc
for name, svc in load_services({"services": [config]}).items():
svc.service_dir = service_dir
loaded[name] = svc
return loaded