"""FastAPI app for the local GovOPlaN release console.""" from __future__ import annotations from pathlib import Path from fastapi import FastAPI, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from pydantic import BaseModel, Field from govoplan_release import ( build_dashboard, build_release_intelligence, build_release_plan, build_selective_catalog_candidate, build_selective_release_plan, publish_catalog_candidate, prepare_repositories, push_repositories, sync_repositories, ) from govoplan_release.model import to_jsonable from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT class CatalogCandidateRequest(BaseModel): repo_versions: dict[str, str] = Field(default_factory=dict) repos: list[str] = Field(default_factory=list) target_version: str | None = None channel: str = "stable" signing_keys: list[str] = Field(default_factory=list) output_dir: str | None = None base_catalog: str | None = None expires_days: int = 90 sequence: int | None = None public_base_url: str = "https://govoplan.add-ideas.de" repository_base: str = "git+ssh://git@git.add-ideas.de/add-ideas" check_public: bool = True class PublishCandidateRequest(BaseModel): candidate_dir: str channel: str = "stable" web_root: str | None = None apply: bool = False # noqa: A003 - API field mirrors CLI. build_web: bool = False commit: bool = False tag: bool = False push: bool = False remote: str = "origin" branch: str | None = None tag_name: str | None = None npm: str = "npm" allow_dirty_website: bool = False confirm: str = "" class RepositoryPushRequest(BaseModel): repos: list[str] = Field(default_factory=list) remote: str = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositorySyncRequest(BaseModel): repos: list[str] = Field(default_factory=list) remote: str = "origin" apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" class RepositoryPrepareRequest(BaseModel): repos: list[str] = Field(default_factory=list) repo_versions: dict[str, str] = Field(default_factory=dict) message: str | None = None apply: bool = False # noqa: A003 - API field mirrors CLI. confirm: str = "" def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI: app = FastAPI(title="GovOPlaN Release Console", version="0.1.0") app.state.workspace_root = workspace_root app.state.token = token @app.middleware("http") async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] if token and request.url.path.startswith("/api/"): provided = request.headers.get("x-release-console-token") or request.query_params.get("token") if provided != token: return JSONResponse({"detail": "release console token required"}, status_code=401) return await call_next(request) @app.get("/", response_class=HTMLResponse) def index() -> HTMLResponse: index_path = Path(__file__).resolve().parents[1] / "webui" / "index.html" if not index_path.exists(): raise HTTPException(status_code=500, detail="release console UI is missing") return HTMLResponse(index_path.read_text(encoding="utf-8"), headers={"Cache-Control": "no-store"}) @app.get("/api/health") def health() -> dict[str, object]: return { "ok": True, "workspace_root": str(app.state.workspace_root), "token_required": bool(app.state.token), } @app.get("/api/dashboard") def dashboard( target_version: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, include_website: bool = False, channel: str = "stable", ) -> dict[str, object]: snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, include_website=include_website, channel=channel, ) return to_jsonable(snapshot) @app.get("/api/plan") def plan( target_version: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, channel=channel, ) release_plan = build_release_plan(snapshot) return to_jsonable(release_plan) @app.get("/api/release-intelligence") def release_intelligence( channel: str = "stable", public_catalog: bool = True, ) -> dict[str, object]: intelligence = build_release_intelligence( workspace_root=app.state.workspace_root, channel=channel, prefer_public=public_catalog, ) return to_jsonable(intelligence) @app.get("/api/selective-plan") def selective_plan( repos: str | None = None, target_version: str | None = None, repo_versions: str | None = None, online: bool = False, remote_tags: bool = False, public_catalog: bool = True, include_migrations: bool = False, channel: str = "stable", ) -> dict[str, object]: snapshot = build_dashboard( workspace_root=app.state.workspace_root, target_version=target_version, online=online, check_remote_tags=online or remote_tags, check_public_catalog=public_catalog, include_migrations=include_migrations, channel=channel, ) release_plan = build_selective_release_plan( snapshot, selected_repos=parse_csv(repos), target_version=target_version, repo_versions=parse_repo_versions(repo_versions), channel=channel, ) return to_jsonable(release_plan) @app.get("/api/catalog-candidates") def catalog_candidates() -> dict[str, object]: return {"candidates": list_catalog_candidates()} @app.post("/api/catalog-candidates") def catalog_candidate(request: CatalogCandidateRequest) -> dict[str, object]: repo_versions = dict(request.repo_versions) if not repo_versions and request.repos and request.target_version: repo_versions = {repo: request.target_version for repo in request.repos} if not repo_versions: raise HTTPException(status_code=400, detail="repo_versions or repos with target_version is required") signing_keys = tuple(request.signing_keys or default_signing_keys()) if not signing_keys: raise HTTPException(status_code=400, detail="No signing key supplied and default release key was not found") candidate = build_selective_catalog_candidate( repo_versions=repo_versions, channel=request.channel, workspace_root=app.state.workspace_root, output_dir=request.output_dir, base_catalog=request.base_catalog, signing_keys=signing_keys, public_base_url=request.public_base_url, repository_base=request.repository_base, expires_days=request.expires_days, sequence=request.sequence, check_public=request.check_public, ) return to_jsonable(candidate) @app.post("/api/catalog-candidates/publish") def publish_candidate(request: PublishCandidateRequest) -> dict[str, object]: if request.push and request.confirm != "PUSH": raise HTTPException(status_code=400, detail="Push requires confirm=PUSH") if (request.apply or request.commit or request.tag or request.build_web) and not request.push and request.confirm != "APPLY": raise HTTPException(status_code=400, detail="Apply/commit/tag/build requires confirm=APPLY") result = publish_catalog_candidate( candidate_dir=request.candidate_dir, workspace_root=app.state.workspace_root, web_root=request.web_root, channel=request.channel, apply=request.apply, build_web=request.build_web, commit=request.commit, tag=request.tag, push=request.push, remote=request.remote, branch=request.branch, tag_name=request.tag_name, npm=request.npm, allow_dirty_website=request.allow_dirty_website, ) return to_jsonable(result) @app.post("/api/repositories/push") def repository_push(request: RepositoryPushRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply and request.confirm != "PUSH": raise HTTPException(status_code=400, detail="Repository push requires confirm=PUSH") result = push_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @app.post("/api/repositories/sync") def repository_sync(request: RepositorySyncRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply and request.confirm != "SYNC": raise HTTPException(status_code=400, detail="Repository sync requires confirm=SYNC") result = sync_repositories(repos=repos, workspace_root=app.state.workspace_root, remote=request.remote, apply=request.apply) return to_jsonable(result) @app.post("/api/repositories/prepare") def repository_prepare(request: RepositoryPrepareRequest) -> dict[str, object]: repos = tuple(dict.fromkeys(repo.strip() for repo in request.repos if repo.strip())) if not repos: raise HTTPException(status_code=400, detail="At least one repository must be selected") if request.apply and request.confirm != "COMMIT": raise HTTPException(status_code=400, detail="Repository prepare requires confirm=COMMIT") result = prepare_repositories( repos=repos, repo_versions=request.repo_versions, message=request.message, workspace_root=app.state.workspace_root, apply=request.apply, ) return to_jsonable(result) return app def parse_csv(value: str | None) -> tuple[str, ...]: if not value: return () return tuple(item.strip() for item in value.split(",") if item.strip()) def parse_repo_versions(value: str | None) -> dict[str, str]: if not value: return {} result: dict[str, str] = {} for item in value.split(","): if ":" not in item: continue repo, version = item.split(":", 1) repo = repo.strip() version = version.strip() if repo and version: result[repo] = version return result def default_signing_keys() -> tuple[str, ...]: key_path = Path.home() / ".config" / "govoplan" / "release-keys" / "release-key-1.pem" if not key_path.exists(): return () return (f"release-key-1={key_path}",) def list_catalog_candidates() -> list[dict[str, object]]: root = META_ROOT / "runtime" / "release-candidates" if not root.exists(): return [] candidates: list[dict[str, object]] = [] for path in sorted((item for item in root.iterdir() if item.is_dir()), reverse=True): summary = path / "summary.json" if summary.exists(): try: import json payload = json.loads(summary.read_text(encoding="utf-8")) if isinstance(payload, dict): payload.setdefault("candidate_dir", str(path)) candidates.append(payload) continue except json.JSONDecodeError: pass candidates.append({"candidate_dir": str(path), "status": "unknown"}) return candidates