service-update-alerts/update_monitor/comparator.py
Jeena f55f3d8e91 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.
2026-04-09 12:49:57 +00:00

71 lines
2.2 KiB
Python

import re
from typing import Any, Dict, List, Optional, Tuple
def normalize_version(raw: Optional[str]) -> Optional[str]:
if not raw:
return None
return raw.strip().lstrip("v")
def parse_version(value: str) -> Optional[Dict[str, Any]]:
match = re.match(
r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$",
value,
)
if not match:
return None
major, minor, patch, prerelease, _build = match.groups()
prerelease_parts: Optional[List[Tuple]] = None
if prerelease:
prerelease_parts = []
for part in prerelease.split("."):
if part.isdigit():
prerelease_parts.append((True, int(part)))
else:
prerelease_parts.append((False, part))
return {
"major": int(major),
"minor": int(minor),
"patch": int(patch),
"prerelease": prerelease_parts,
}
def _compare_prerelease(a: Optional[list], b: Optional[list]) -> int:
if a is None and b is None:
return 0
if a is None:
return -1
if b is None:
return 1
for (a_is_num, a_val), (b_is_num, b_val) in zip(a, b):
if a_is_num and b_is_num:
if a_val != b_val:
return 1 if a_val > b_val else -1
elif a_is_num != b_is_num:
return -1 if a_is_num else 1
else:
if a_val != b_val:
return 1 if a_val > b_val else -1
if len(a) == len(b):
return 0
return 1 if len(a) > len(b) else -1
def compare_versions(current: Optional[str], latest: Optional[str]) -> Optional[int]:
current_norm = normalize_version(current)
latest_norm = normalize_version(latest)
if not current_norm or not latest_norm:
return None
current_v = parse_version(current_norm)
latest_v = parse_version(latest_norm)
if current_v is None or latest_v is None:
return None
c = (current_v["major"], current_v["minor"], current_v["patch"])
l = (latest_v["major"], latest_v["minor"], latest_v["patch"])
if l > c:
return 1
if l < c:
return -1
return _compare_prerelease(current_v["prerelease"], latest_v["prerelease"])