service-update-alerts/update_monitor/__main__.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

131 lines
5.5 KiB
Python

import argparse
import sys
from .checker import build_summary, check_service
from .classifier import Classifier
from .comparator import compare_versions
from .loader import load_dotenv, load_services_dir
from .notifier import send_matrix_message, send_uptime_kuma_ping
from .updater import run_update_script
def main() -> int:
parser = argparse.ArgumentParser(description="Check for webservice updates")
parser.add_argument("--services", default="services", help="Path to services directory")
parser.add_argument("--all", action="store_true", help="Show all services")
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds")
parser.add_argument("--user-agent", default="service-update-alerts/1.0", help="HTTP user agent")
parser.add_argument("--auto-update", action="store_true", help="Automatically update services when safe")
parser.add_argument("--classify-timeout", type=float, default=300.0, help="Timeout for Ollama classification in seconds")
parser.add_argument("--script-timeout", type=float, default=300.0, help="Timeout for auto-update scripts in seconds")
args = parser.parse_args()
load_dotenv()
try:
services = load_services_dir(args.services)
except Exception as exc:
print(f"Failed to load services: {exc}", file=sys.stderr)
return 1
results = [check_service(svc, args.timeout, args.user_agent) for svc in services.values()]
output_lines = []
for result in sorted(results, key=lambda r: r["name"].lower()):
comparison = compare_versions(result["current"], result["latest"])
has_update = comparison == 1
if not args.all and not has_update:
continue
current = result["current"] or "unknown"
latest = result["latest"] or "unknown"
notes = []
if comparison is None and result["current"] and result["latest"]:
notes.append("unparseable")
if result["current_error"]:
notes.append("current error")
if result["latest_error"]:
notes.append("latest error")
suffix = f" [{' '.join(notes)}]" if notes else ""
output_lines.append(f"{result['name']}: {current} -> {latest}{suffix}")
if output_lines:
print("\n".join(output_lines))
for result in sorted(results, key=lambda r: r["name"].lower()):
if result["current_error"]:
print(f"{result['name']}: current version error: {result['current_error']}", file=sys.stderr)
if result["latest_error"]:
print(f"{result['name']}: latest version error: {result['latest_error']}", file=sys.stderr)
# Build one consolidated Matrix message
matrix_sections = []
if args.auto_update:
classifier = Classifier()
auto_updated = []
manual_needed = []
update_pending = []
for result in results:
if compare_versions(result["current"], result["latest"]) != 1:
continue
service = services[result["name"]]
current = result["current"] or "unknown"
latest = result["latest"] or "unknown"
script = service.service_dir / "auto-update.sh" if service.service_dir else None
if not script or not script.exists():
update_pending.append(f"- {service.name}: {current} -> {latest}")
continue
classification = classifier.classify(
service.service_dir,
result.get("release_notes"),
current,
latest,
timeout=args.classify_timeout,
)
if classification["action"] == "auto":
script_result = run_update_script(script, current, latest, timeout=args.script_timeout)
if script_result["success"]:
msg = f"- {service.name}: {current} -> {latest}\n Reason: {classification['reason']}"
if script_result["output"]:
msg += f"\n Output: {script_result['output']}"
auto_updated.append(msg)
else:
msg = f"- {service.name}: {current} -> {latest}\n Error: {script_result['error']}"
manual_needed.append(msg)
else:
msg = f"- {service.name}: {current} -> {latest}\n Reason: {classification['reason']}"
manual_needed.append(msg)
if auto_updated:
matrix_sections.append("Auto-updated:\n" + "\n".join(auto_updated))
if manual_needed:
matrix_sections.append("Manual update needed:\n" + "\n".join(manual_needed))
if update_pending:
matrix_sections.append("Updates available (no auto-update configured):\n" + "\n".join(update_pending))
else:
summary, should_notify = build_summary(results)
if should_notify:
matrix_sections.append(summary)
errors = [r for r in results if r["current_error"] or r["latest_error"]]
if errors:
error_lines = []
for r in errors:
if r["current_error"]:
error_lines.append(f"- {r['name']}: current version error")
if r["latest_error"]:
error_lines.append(f"- {r['name']}: latest version error")
matrix_sections.append("Errors:\n" + "\n".join(error_lines))
if matrix_sections:
send_matrix_message("\n\n".join(matrix_sections), args.timeout)
send_uptime_kuma_ping(args.timeout)
return 0
if __name__ == "__main__":
raise SystemExit(main())