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

View file

@ -0,0 +1,77 @@
import json
import re
from typing import Any, Dict, Optional
from .models import ExtractRule
def extract_jsonpath(payload: Any, path: str) -> Optional[str]:
if not path.startswith("$."):
return None
current = payload
for part in path[2:].split("."):
if isinstance(current, list):
if not part.isdigit():
return None
index = int(part)
if index >= len(current):
return None
current = current[index]
continue
if not isinstance(current, dict):
return None
if part not in current:
return None
current = current[part]
return None if current is None else str(current)
def extract_version(
body: str,
rule: ExtractRule,
headers: Optional[Dict[str, str]] = None,
) -> Optional[str]:
if rule.type == "jsonpath":
try:
return extract_jsonpath(json.loads(body), rule.value)
except json.JSONDecodeError:
return None
if rule.type == "jsonpath_join":
try:
payload = json.loads(body)
except json.JSONDecodeError:
return None
parts = []
for path in rule.value.split(","):
value = extract_jsonpath(payload, path.strip())
if value is None:
return None
parts.append(value)
return ".".join(parts)
if rule.type == "header":
return headers.get(rule.value.lower()) if headers else None
if rule.type == "regex":
match = re.search(rule.value, body)
if not match:
return None
return match.group(1) if match.lastindex else match.group(0)
if rule.type == "text":
return rule.value if rule.value in body else None
return None
def build_upstream_fallback(url: Optional[str]) -> Optional[Dict[str, Any]]:
if not url:
return None
for host in ("api.github.com/repos/", "codeberg.org/api/v1/repos/"):
if host in url and url.endswith("/releases/latest"):
return {
"url": url.replace("/releases/latest", "/tags"),
"extract": ExtractRule(type="jsonpath", value="$.0.name"),
}
return None