"""Release catalog intelligence for the local release cockpit.""" from __future__ import annotations from datetime import UTC, datetime import json from pathlib import Path import re from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json from .git_state import collect_repository_snapshot from .workspace import load_repository_specs, resolve_workspace_root, website_root def build_release_intelligence( *, workspace_root: Path | str | None = None, channel: str = "stable", public_base_url: str = DEFAULT_PUBLIC_BASE_URL, prefer_public: bool = True, ) -> dict[str, object]: workspace = resolve_workspace_root(workspace_root) web_root = website_root(workspace) local_catalog_path = web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json" local_keyring_path = web_root / "public" / "catalogs" / "v1" / "keyring.json" public_catalog_url = f"{public_base_url.rstrip('/')}/catalogs/v1/channels/{channel}.json" public_keyring_url = f"{public_base_url.rstrip('/')}/catalogs/v1/keyring.json" local_catalog = read_json_file(local_catalog_path) local_keyring = read_json_file(local_keyring_path) public_catalog = fetch_json(public_catalog_url) if prefer_public else {"ok": False, "error": "public check disabled"} public_keyring = fetch_json(public_keyring_url) if prefer_public else {"ok": False, "error": "public check disabled"} catalog_source = "public" if prefer_public and public_catalog.get("ok") else "local" keyring_source = "public" if prefer_public and public_keyring.get("ok") else "local" catalog_payload = public_catalog["payload"] if catalog_source == "public" else local_catalog.get("payload") keyring_payload = public_keyring["payload"] if keyring_source == "public" else local_keyring.get("payload") modules = module_rows(catalog_payload) compatibility = compatibility_rows(modules) signatures = signature_rows(catalog_payload, keyring_payload) local_versions = repository_versions(workspace) for module in modules: repo = module.get("repo") module["local_version"] = local_versions.get(repo) if isinstance(repo, str) else None module["local_matches_catalog"] = module.get("version") == module.get("local_version") if module.get("local_version") else None local_catalog_hash = canonical_hash(local_catalog["payload"]) if local_catalog.get("ok") else None public_catalog_hash = canonical_hash(public_catalog["payload"]) if public_catalog.get("ok") else None local_keyring_hash = canonical_hash(local_keyring["payload"]) if local_keyring.get("ok") else None public_keyring_hash = canonical_hash(public_keyring["payload"]) if public_keyring.get("ok") else None issues = health_issues( catalog_payload=catalog_payload, keyring_payload=keyring_payload, local_catalog_hash=local_catalog_hash, public_catalog_hash=public_catalog_hash, local_keyring_hash=local_keyring_hash, public_keyring_hash=public_keyring_hash, signatures=signatures, compatibility=compatibility, ) return { "generated_at": json_datetime(datetime.now(tz=UTC)), "channel": channel, "catalog_source": catalog_source, "keyring_source": keyring_source, "catalog": catalog_summary( catalog_payload, source=catalog_source, local_path=str(local_catalog_path), public_url=public_catalog_url, local_ok=bool(local_catalog.get("ok")), public_ok=bool(public_catalog.get("ok")), local_hash=local_catalog_hash, public_hash=public_catalog_hash, public_error=str(public_catalog.get("error")) if public_catalog.get("error") else None, ), "keyring": keyring_summary( keyring_payload, source=keyring_source, local_path=str(local_keyring_path), public_url=public_keyring_url, local_ok=bool(local_keyring.get("ok")), public_ok=bool(public_keyring.get("ok")), local_hash=local_keyring_hash, public_hash=public_keyring_hash, public_error=str(public_keyring.get("error")) if public_keyring.get("error") else None, ), "modules": modules, "signatures": signatures, "compatibility": compatibility, "issues": issues, "summary": { "status": status_from_issues(issues), "published_modules": len(modules), "signed": bool(signatures), "trusted_signatures": sum(1 for item in signatures if item.get("trusted") is True), "compatibility_blockers": sum(1 for item in compatibility if item.get("severity") == "blocker"), "compatibility_warnings": sum(1 for item in compatibility if item.get("severity") == "warning"), "catalog_matches_public": compare_hashes(local_catalog_hash, public_catalog_hash), "keyring_matches_public": compare_hashes(local_keyring_hash, public_keyring_hash), }, } def read_json_file(path: Path) -> dict[str, object]: if not path.exists(): return {"ok": False, "error": "missing"} try: return {"ok": True, "payload": json.loads(path.read_text(encoding="utf-8"))} except (OSError, json.JSONDecodeError) as exc: return {"ok": False, "error": str(exc)} def catalog_summary( payload: object, *, source: str, local_path: str, public_url: str, local_ok: bool, public_ok: bool, local_hash: str | None, public_hash: str | None, public_error: str | None, ) -> dict[str, object]: catalog = payload if isinstance(payload, dict) else {} core = catalog.get("core_release") if isinstance(catalog.get("core_release"), dict) else {} modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else [] signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else [] return { "source": source, "local_path": local_path, "public_url": public_url, "local_ok": local_ok, "public_ok": public_ok, "public_error": public_error, "local_hash": local_hash, "public_hash": public_hash, "matches_public": compare_hashes(local_hash, public_hash), "catalog_version": catalog.get("catalog_version"), "sequence": catalog.get("sequence"), "generated_at": catalog.get("generated_at"), "expires_at": catalog.get("expires_at"), "core_version": core.get("version") if isinstance(core, dict) else None, "module_count": len(modules), "signature_count": len(signatures), } def keyring_summary( payload: object, *, source: str, local_path: str, public_url: str, local_ok: bool, public_ok: bool, local_hash: str | None, public_hash: str | None, public_error: str | None, ) -> dict[str, object]: keyring = payload if isinstance(payload, dict) else {} keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else [] return { "source": source, "local_path": local_path, "public_url": public_url, "local_ok": local_ok, "public_ok": public_ok, "public_error": public_error, "local_hash": local_hash, "public_hash": public_hash, "matches_public": compare_hashes(local_hash, public_hash), "generated_at": keyring.get("generated_at"), "key_count": len(keys), "active_key_count": sum(1 for item in keys if isinstance(item, dict) and item.get("status") == "active"), } def module_rows(payload: object) -> list[dict[str, object]]: catalog = payload if isinstance(payload, dict) else {} raw_modules = catalog.get("modules") if isinstance(catalog.get("modules"), list) else [] modules: list[dict[str, object]] = [] for item in raw_modules: if not isinstance(item, dict): continue repo = module_repo(item) modules.append( { "module_id": string(item.get("module_id")), "name": string(item.get("name")), "version": string(item.get("version")), "repo": repo, "python_ref": string(item.get("python_ref")), "python_tag": ref_tag(string(item.get("python_ref"))), "webui_ref": string(item.get("webui_ref")), "webui_tag": ref_tag(string(item.get("webui_ref"))), "provides_interfaces": interface_list(item.get("provides_interfaces")), "requires_interfaces": requirement_list(item.get("requires_interfaces")), "migration_safety": string(item.get("migration_safety")), "tags": tuple(str(value) for value in item.get("tags", ()) if isinstance(value, str)) if isinstance(item.get("tags"), list) else (), } ) return sorted(modules, key=lambda row: str(row.get("module_id") or row.get("repo") or "")) def module_repo(entry: dict[str, object]) -> str | None: for field in ("python_ref", "webui_ref"): value = entry.get(field) if isinstance(value, str): match = re.search(r"/([^/@#]+)[.]git(?:[@#]|$)", value) if match: return match.group(1) package = entry.get("python_package") if isinstance(package, str) and package.startswith("govoplan-"): return package.split("[", 1)[0] return None def ref_tag(value: str | None) -> str | None: if not value: return None if "#" in value: return value.rsplit("#", 1)[-1] if "@" in value: return value.rsplit("@", 1)[-1] return None def interface_list(value: object) -> tuple[dict[str, str], ...]: if not isinstance(value, list): return () result: list[dict[str, str]] = [] for item in value: if not isinstance(item, dict): continue name = string(item.get("name")) version = string(item.get("version")) if name and version: result.append({"name": name, "version": version}) return tuple(result) def requirement_list(value: object) -> tuple[dict[str, object], ...]: if not isinstance(value, list): return () result: list[dict[str, object]] = [] for item in value: if not isinstance(item, dict): continue name = string(item.get("name")) if not name: continue result.append( { "name": name, "version_min": string(item.get("version_min")), "version_max_exclusive": string(item.get("version_max_exclusive")), "optional": bool(item.get("optional")), } ) return tuple(result) def compatibility_rows(modules: list[dict[str, object]]) -> list[dict[str, object]]: providers: dict[str, list[dict[str, object]]] = {} for module in modules: for provided in module.get("provides_interfaces", ()): if isinstance(provided, dict): providers.setdefault(str(provided.get("name")), []).append( { "module_id": module.get("module_id"), "repo": module.get("repo"), "version": provided.get("version"), } ) rows: list[dict[str, object]] = [] for module in modules: for requirement in module.get("requires_interfaces", ()): if not isinstance(requirement, dict): continue name = string(requirement.get("name")) if not name: continue optional = bool(requirement.get("optional")) available = providers.get(name, []) satisfied = [provider for provider in available if version_satisfies(string(provider.get("version")), requirement)] if satisfied: severity = "ok" status = "satisfied" elif available: severity = "warning" if optional else "blocker" status = "version-mismatch" else: severity = "info" if optional else "blocker" status = "optional-missing" if optional else "missing" rows.append( { "module_id": module.get("module_id"), "repo": module.get("repo"), "interface": name, "requirement": format_requirement(requirement), "optional": optional, "status": status, "severity": severity, "providers": available, "satisfied_by": satisfied, } ) return sorted(rows, key=lambda row: (str(row.get("severity")), str(row.get("module_id")), str(row.get("interface")))) def signature_rows(catalog_payload: object, keyring_payload: object) -> list[dict[str, object]]: catalog = catalog_payload if isinstance(catalog_payload, dict) else {} keyring = keyring_payload if isinstance(keyring_payload, dict) else {} signatures = catalog.get("signatures") if isinstance(catalog.get("signatures"), list) else [] keys = keyring.get("keys") if isinstance(keyring.get("keys"), list) else [] trusted = { string(item.get("key_id")): item for item in keys if isinstance(item, dict) and string(item.get("key_id")) } rows: list[dict[str, object]] = [] for item in signatures: if not isinstance(item, dict): continue key_id = string(item.get("key_id")) key = trusted.get(key_id) rows.append( { "key_id": key_id, "algorithm": string(item.get("algorithm")), "trusted": key is not None and key.get("status") == "active", "key_status": string(key.get("status")) if isinstance(key, dict) else None, } ) return rows def repository_versions(workspace: Path) -> dict[str | None, str | None]: versions: dict[str | None, str | None] = {} for spec in load_repository_specs(include_website=False): snapshot = collect_repository_snapshot(spec, workspace_root=workspace, target_tag=None, online=False) versions[spec.name] = snapshot.versions.primary return versions def health_issues( *, catalog_payload: object, keyring_payload: object, local_catalog_hash: str | None, public_catalog_hash: str | None, local_keyring_hash: str | None, public_keyring_hash: str | None, signatures: list[dict[str, object]], compatibility: list[dict[str, object]], ) -> list[dict[str, str]]: issues: list[dict[str, str]] = [] if not isinstance(catalog_payload, dict): issues.append({"severity": "blocker", "code": "catalog_missing", "message": "No usable catalog is available."}) if not isinstance(keyring_payload, dict): issues.append({"severity": "blocker", "code": "keyring_missing", "message": "No usable keyring is available."}) if not signatures: issues.append({"severity": "blocker", "code": "catalog_unsigned", "message": "The selected catalog has no signatures."}) elif not any(item.get("trusted") is True for item in signatures): issues.append({"severity": "blocker", "code": "signature_untrusted", "message": "No catalog signature matches an active keyring key."}) if compare_hashes(local_catalog_hash, public_catalog_hash) is False: issues.append({"severity": "warning", "code": "catalog_drift", "message": "Local and published catalogs differ."}) if compare_hashes(local_keyring_hash, public_keyring_hash) is False: issues.append({"severity": "warning", "code": "keyring_drift", "message": "Local and published keyrings differ."}) blockers = [item for item in compatibility if item.get("severity") == "blocker"] warnings = [item for item in compatibility if item.get("severity") == "warning"] if blockers: issues.append({"severity": "blocker", "code": "compatibility_blockers", "message": f"{len(blockers)} compatibility blocker(s) found."}) if warnings: issues.append({"severity": "warning", "code": "compatibility_warnings", "message": f"{len(warnings)} compatibility warning(s) found."}) return issues def version_satisfies(version: str | None, requirement: dict[str, object]) -> bool: parsed = parse_version(version) if parsed is None: return False minimum = parse_version(string(requirement.get("version_min"))) maximum = parse_version(string(requirement.get("version_max_exclusive"))) if minimum is not None and parsed < minimum: return False if maximum is not None and parsed >= maximum: return False return True def parse_version(value: str | None) -> tuple[int, ...] | None: if not value: return None core = value.removeprefix("v").split("-", 1)[0] parts = core.split(".") if not parts or any(not part.isdigit() for part in parts): return None return tuple(int(part) for part in parts) def format_requirement(requirement: dict[str, object]) -> str: parts: list[str] = [] if requirement.get("version_min"): parts.append(f">={requirement['version_min']}") if requirement.get("version_max_exclusive"): parts.append(f"<{requirement['version_max_exclusive']}") return ", ".join(parts) if parts else "any" def status_from_issues(issues: list[dict[str, str]]) -> str: if any(item.get("severity") == "blocker" for item in issues): return "blocked" if any(item.get("severity") == "warning" for item in issues): return "attention" return "ready" def compare_hashes(local_hash: str | None, public_hash: str | None) -> bool | None: if local_hash is None or public_hash is None: return None return local_hash == public_hash def string(value: object) -> str | None: return value if isinstance(value, str) else None def json_datetime(value: datetime) -> str: return value.replace(microsecond=0).isoformat().replace("+00:00", "Z")