#!/usr/bin/env python3 """Report active-fleet Repo Classification convergence from State Hub. The repository file remains authoritative. This command compares the live State Hub projection with each active record's registered local checkout and separates missing owner decisions from projection failures. """ from __future__ import annotations import argparse import json import os import sys import urllib.error import urllib.request from pathlib import Path from typing import Any, Callable import yaml REPO_ROOT = Path(__file__).resolve().parent.parent if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from tools.validate_repo_classification import load_allowed, validate # noqa: E402 DEFAULT_API_BASE = "http://127.0.0.1:8000" UrlOpen = Callable[..., Any] class FleetReadError(RuntimeError): """State Hub did not return a usable repository projection.""" def fetch_repositories( api_base: str, *, timeout: float = 15.0, opener: UrlOpen = urllib.request.urlopen, ) -> list[dict[str, Any]]: url = f"{api_base.rstrip('/')}/repos/?limit=500" request = urllib.request.Request(url, headers={"Accept": "application/json"}) try: with opener(request, timeout=timeout) as response: payload = json.load(response) except (OSError, urllib.error.URLError, json.JSONDecodeError) as exc: raise FleetReadError(f"cannot read State Hub repositories: {exc}") from exc if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload): raise FleetReadError("State Hub /repos response is not a list of objects") return payload def _source_path(record: dict[str, Any]) -> Path | None: local_path = record.get("local_path") if not isinstance(local_path, str) or not local_path.strip(): return None return Path(local_path) / ".repo-classification.yaml" def analyze_repositories( records: list[dict[str, Any]], *, validate_sources: bool = True, ) -> dict[str, Any]: active = [record for record in records if record.get("status") == "active"] classified = [record for record in active if record.get("category") is not None] null_records = [record for record in active if record.get("category") is None] missing_source: list[str] = [] present_unprojected: list[str] = [] projected_without_source: list[str] = [] invalid_source: dict[str, list[str]] = {} source_warnings: dict[str, list[str]] = {} duplicate_slugs: list[str] = [] slug_counts: dict[str, int] = {} allowed = load_allowed() if validate_sources else None for record in active: slug = str(record.get("slug") or "") slug_counts[slug] = slug_counts.get(slug, 0) + 1 path = _source_path(record) source_present = path is not None and path.is_file() if record.get("category") is None: (present_unprojected if source_present else missing_source).append(slug) elif not source_present: projected_without_source.append(slug) if validate_sources and source_present and path is not None: try: document = yaml.safe_load(path.read_text(encoding="utf-8")) errors, warnings = validate(document, allowed) except (OSError, yaml.YAMLError) as exc: errors, warnings = [f"cannot read/parse source: {exc}"], [] if errors: invalid_source[slug] = errors if warnings: source_warnings[slug] = warnings duplicate_slugs = sorted(slug for slug, count in slug_counts.items() if count > 1) missing_source.sort() present_unprojected.sort() projected_without_source.sort() blocking = bool( missing_source or present_unprojected or projected_without_source or invalid_source or duplicate_slugs ) return { "registered": len(records), "active": len(active), "active_classified": len(classified), "active_null_category": len(null_records), "missing_source": missing_source, "present_unprojected": present_unprojected, "projected_without_source": projected_without_source, "invalid_source": dict(sorted(invalid_source.items())), "source_warnings": dict(sorted(source_warnings.items())), "source_warning_count": sum(len(items) for items in source_warnings.values()), "duplicate_active_slugs": duplicate_slugs, "converged": not blocking, } def render_text(report: dict[str, Any], *, show_warnings: bool = False) -> str: lines = [ f"registered: {report['registered']}", f"active: {report['active']}", f"active classified: {report['active_classified']}", f"active null category: {report['active_null_category']}", f"missing source file: {len(report['missing_source'])}", f"source present but unprojected: {len(report['present_unprojected'])}", f"projected without source file: {len(report['projected_without_source'])}", f"invalid source file: {len(report['invalid_source'])}", f"source validation warnings: {report['source_warning_count']}", f"duplicate active slug: {len(report['duplicate_active_slugs'])}", f"converged: {'yes' if report['converged'] else 'no'}", ] for key in ( "missing_source", "present_unprojected", "projected_without_source", "duplicate_active_slugs", ): if report[key]: lines.append(f"{key}: {', '.join(report[key])}") for slug, errors in report["invalid_source"].items(): lines.append(f"invalid_source[{slug}]: {'; '.join(errors)}") if show_warnings: for slug, warnings in report["source_warnings"].items(): lines.append(f"source_warnings[{slug}]: {'; '.join(warnings)}") return "\n".join(lines) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--api-base", default=os.environ.get("STATE_HUB_API_BASE", DEFAULT_API_BASE), help="State Hub REST base URL (default: %(default)s)", ) parser.add_argument("--timeout", type=float, default=15.0) parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") parser.add_argument( "--show-warnings", action="store_true", help="include individual non-blocking source validation warnings", ) parser.add_argument( "--skip-source-validation", action="store_true", help="only compare file presence and projection state", ) parser.add_argument( "--require-converged", action="store_true", help="exit 1 while any active classification gap remains", ) return parser def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) try: records = fetch_repositories(args.api_base, timeout=args.timeout) report = analyze_repositories( records, validate_sources=not args.skip_source_validation, ) except FleetReadError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 if args.json: print(json.dumps(report, indent=2, sort_keys=True)) else: print(render_text(report, show_warnings=args.show_warnings)) return 1 if args.require_converged and not report["converged"] else 0 if __name__ == "__main__": raise SystemExit(main())