the-custodian/tools/repo_classification_convergence.py
codex ff334f1a40
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Python Tests / pytest (push) Successful in 21s
feat: automate classification convergence
2026-08-23 13:05:47 +02:00

204 lines
7.4 KiB
Python

#!/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 "<missing-slug>")
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())