#!/usr/bin/env python3 """Build GovOPlaN release catalog candidates.""" from __future__ import annotations import argparse import json from pathlib import Path from govoplan_release.module_directory import write_module_directory from govoplan_release.model import to_jsonable from govoplan_release.publisher import publish_catalog_candidate from govoplan_release.selective_catalog import build_selective_catalog_candidate from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT def main() -> int: parser = argparse.ArgumentParser(description=__doc__) subparsers = parser.add_subparsers(dest="command", required=True) selective = subparsers.add_parser("selective", help="Build a signed selective channel catalog candidate.") selective.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT) selective.add_argument("--channel", default="stable") selective.add_argument("--repo-version", action="append", default=[], metavar="REPO=VERSION", required=True) selective.add_argument("--catalog-signing-key", action="append", default=[], metavar="KEY_ID=PRIVATE_KEY", required=True) selective.add_argument("--base-catalog", help="Catalog path or URL to use as the source. Defaults to local channel, then public channel.") selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/-.") selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de") selective.add_argument("--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas") selective.add_argument("--expires-days", type=int, default=90) selective.add_argument("--sequence", type=int) selective.add_argument("--skip-public-check", action="store_true") selective.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.") publish = subparsers.add_parser("publish-candidate", help="Publish a reviewed catalog candidate into the website repo.") publish.add_argument("--candidate-dir", type=Path, required=True) publish.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT) publish.add_argument("--web-root", type=Path) publish.add_argument("--channel", default="stable") publish.add_argument("--apply", action="store_true", help="Copy candidate files into the website repo. Without this, only preview.") publish.add_argument("--build-web", action="store_true") publish.add_argument("--commit", action="store_true") publish.add_argument("--tag", action="store_true", help="Create a website catalog publication tag. Implies --commit.") publish.add_argument("--push", action="store_true", help="Push the website branch and tag. Implies --commit.") publish.add_argument("--remote", default="origin") publish.add_argument("--branch") publish.add_argument("--tag-name") publish.add_argument("--npm", default="npm") publish.add_argument("--allow-dirty-website", action="store_true") publish.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.") directory = subparsers.add_parser("module-directory", help="Generate browsable module-directory files from a catalog and keyring.") directory.add_argument("--catalog", type=Path, required=True) directory.add_argument("--keyring", type=Path, required=True) directory.add_argument("--output-dir", type=Path, required=True) directory.add_argument("--channel", default="stable") directory.add_argument("--public-base-url", default="https://govoplan.add-ideas.de") directory.add_argument("--json", action="store_true", help="Print machine-readable summary JSON.") args = parser.parse_args() if args.command == "selective": result = build_selective_catalog_candidate( repo_versions=parse_repo_versions(tuple(args.repo_version)), channel=args.channel, workspace_root=args.workspace_root, output_dir=args.output_dir, base_catalog=args.base_catalog, signing_keys=tuple(args.catalog_signing_key), public_base_url=args.public_base_url, repository_base=args.repository_base, expires_days=args.expires_days, sequence=args.sequence, check_public=not args.skip_public_check, ) payload = to_jsonable(result) if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) else: print_text_summary(payload) return 0 if result.status == "ready" else 1 if args.command == "publish-candidate": result = publish_catalog_candidate( candidate_dir=args.candidate_dir, workspace_root=args.workspace_root, web_root=args.web_root, channel=args.channel, apply=args.apply, build_web=args.build_web, commit=args.commit, tag=args.tag, push=args.push, remote=args.remote, branch=args.branch, npm=args.npm, tag_name=args.tag_name, allow_dirty_website=args.allow_dirty_website, ) payload = to_jsonable(result) if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) else: print_publish_summary(payload) return 0 if result.status in {"ready", "applied", "published"} else 1 if args.command == "module-directory": catalog_payload = json.loads(args.catalog.read_text(encoding="utf-8")) keyring_payload = json.loads(args.keyring.read_text(encoding="utf-8")) files = write_module_directory( catalog_payload=catalog_payload, keyring_payload=keyring_payload, output_root=args.output_dir, channel=args.channel, public_base_url=args.public_base_url, ) payload = {"status": "generated", "output_dir": str(args.output_dir), "files": [str(path) for path in files]} if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) else: print("Module directory") print(f" output_dir: {args.output_dir}") print(f" files: {len(files)}") for path in files[:20]: print(f" {path}") if len(files) > 20: print(f" ... {len(files) - 20} more") return 0 return 2 def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]: result: dict[str, str] = {} for value in values: if "=" not in value: raise SystemExit(f"--repo-version must use REPO=VERSION: {value}") repo, version = value.split("=", 1) repo = repo.strip() version = version.strip().removeprefix("v") if not repo or not version: raise SystemExit(f"--repo-version must use REPO=VERSION: {value}") result[repo] = version return result def print_text_summary(payload: dict[str, object]) -> None: print("Selective catalog candidate") for key in ( "status", "channel", "sequence", "catalog_path", "keyring_path", "summary_path", "validation_valid", "validation_error", "candidate_matches_published_catalog", "candidate_matches_published_keyring", ): print(f" {key}: {payload.get(key)}") changes = payload.get("changes") if isinstance(changes, list): print(f" changes: {len(changes)}") for item in changes[:20]: if not isinstance(item, dict): continue print( " " f"{item.get('repo')}:{item.get('module_id') or '-'} " f"{item.get('field')} {item.get('before')!r} -> {item.get('after')!r}" ) if len(changes) > 20: print(f" ... {len(changes) - 20} more") warnings = payload.get("validation_warnings") if isinstance(warnings, list) and warnings: print(" validation warnings:") for warning in warnings: print(f" {warning}") def print_publish_summary(payload: dict[str, object]) -> None: print("Catalog publish candidate") for key in ( "status", "applied", "channel", "candidate_dir", "web_root", "target_catalog_path", "target_keyring_path", "branch", "tag_name", "validation_valid", "validation_error", "catalog_changed", "keyring_changed", ): print(f" {key}: {payload.get(key)}") steps = payload.get("steps") if isinstance(steps, list): print(" steps:") for item in steps: if not isinstance(item, dict): continue print(f" [{item.get('status')}] {item.get('title')}") command = item.get("command") if command: print(f" {command}") notes = payload.get("notes") if isinstance(notes, list) and notes: print(" notes:") for note in notes: print(f" {note}") if __name__ == "__main__": raise SystemExit(main())