Load projects from versioned webui/data/projects.registry.json with profile
mappings, workflow/schema paths, and read-only onboarding checklist UI.
Seeds Gitea-Tools; exposes /projects, /projects/{id}, and /api/projects.
Closes #427
196 lines
6.1 KiB
Python
196 lines
6.1 KiB
Python
"""Load and validate the web UI project registry (#427)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
_FORBIDDEN_EXACT_KEYS = frozenset({
|
|
"token",
|
|
"password",
|
|
"secret",
|
|
"credential",
|
|
"auth",
|
|
"api_key",
|
|
"api-key",
|
|
})
|
|
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
|
|
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
|
|
|
|
|
|
def _is_forbidden_key(key: str) -> bool:
|
|
lowered = key.lower()
|
|
if lowered in _FORBIDDEN_EXACT_KEYS:
|
|
return True
|
|
return (
|
|
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
|
|
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
|
|
)
|
|
|
|
_REQUIRED_PROJECT_FIELDS = (
|
|
"id",
|
|
"repo_name",
|
|
"gitea_owner",
|
|
"remote_host",
|
|
"default_branch",
|
|
"local_checkout_path",
|
|
"profiles",
|
|
"workflow_paths",
|
|
)
|
|
|
|
_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OnboardingStep:
|
|
id: str
|
|
title: str
|
|
description: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectRecord:
|
|
id: str
|
|
repo_name: str
|
|
gitea_owner: str
|
|
remote_host: str
|
|
default_branch: str
|
|
local_checkout_path: str
|
|
profiles: dict[str, str]
|
|
workflow_paths: dict[str, str]
|
|
schema_paths: dict[str, str]
|
|
onboarding_checklist: tuple[OnboardingStep, ...]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ProjectRegistry:
|
|
version: int
|
|
projects: tuple[ProjectRecord, ...]
|
|
source_path: Path
|
|
|
|
|
|
def default_registry_path() -> Path:
|
|
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
|
|
|
|
|
|
def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
|
|
if isinstance(obj, dict):
|
|
for key, value in obj.items():
|
|
key_path = f"{path}.{key}" if path else key
|
|
if _is_forbidden_key(key):
|
|
raise ValueError(f"registry must not store credentials ({key_path})")
|
|
_reject_credential_keys(value, path=key_path)
|
|
elif isinstance(obj, list):
|
|
for index, item in enumerate(obj):
|
|
_reject_credential_keys(item, path=f"{path}[{index}]")
|
|
|
|
|
|
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
|
|
if not raw:
|
|
return ()
|
|
steps: list[OnboardingStep] = []
|
|
for item in raw:
|
|
steps.append(
|
|
OnboardingStep(
|
|
id=str(item["id"]),
|
|
title=str(item["title"]),
|
|
description=str(item["description"]),
|
|
)
|
|
)
|
|
return tuple(steps)
|
|
|
|
|
|
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
|
|
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
|
|
if missing:
|
|
raise ValueError(f"project missing required fields: {', '.join(missing)}")
|
|
|
|
profiles = raw["profiles"]
|
|
if not isinstance(profiles, dict):
|
|
raise ValueError("profiles must be an object")
|
|
for role in _REQUIRED_PROFILE_ROLES:
|
|
if role not in profiles or not profiles[role]:
|
|
raise ValueError(f"profiles.{role} is required")
|
|
|
|
workflow_paths = raw["workflow_paths"]
|
|
if not isinstance(workflow_paths, dict) or not workflow_paths:
|
|
raise ValueError("workflow_paths must be a non-empty object")
|
|
|
|
schema_paths = raw.get("schema_paths") or {}
|
|
if not isinstance(schema_paths, dict):
|
|
raise ValueError("schema_paths must be an object when present")
|
|
|
|
return ProjectRecord(
|
|
id=str(raw["id"]),
|
|
repo_name=str(raw["repo_name"]),
|
|
gitea_owner=str(raw["gitea_owner"]),
|
|
remote_host=str(raw["remote_host"]),
|
|
default_branch=str(raw["default_branch"]),
|
|
local_checkout_path=str(raw["local_checkout_path"]),
|
|
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
|
|
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
|
|
schema_paths={key: str(value) for key, value in schema_paths.items()},
|
|
onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
|
|
)
|
|
|
|
|
|
def load_registry(path: Path | None = None) -> ProjectRegistry:
|
|
"""Load the versioned project registry from disk."""
|
|
source = (path or default_registry_path()).resolve()
|
|
raw_text = source.read_text(encoding="utf-8")
|
|
payload = json.loads(raw_text)
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("registry root must be an object")
|
|
|
|
version = payload.get("version")
|
|
if version != 1:
|
|
raise ValueError(f"unsupported registry version: {version!r}")
|
|
|
|
_reject_credential_keys(payload)
|
|
|
|
projects_raw = payload.get("projects")
|
|
if not isinstance(projects_raw, list) or not projects_raw:
|
|
raise ValueError("projects must be a non-empty array")
|
|
|
|
projects = tuple(_parse_project(item) for item in projects_raw)
|
|
return ProjectRegistry(version=version, projects=projects, source_path=source)
|
|
|
|
|
|
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
|
|
"""Serialize a project for JSON API responses."""
|
|
return {
|
|
"id": project.id,
|
|
"repo_name": project.repo_name,
|
|
"gitea_owner": project.gitea_owner,
|
|
"remote_host": project.remote_host,
|
|
"default_branch": project.default_branch,
|
|
"local_checkout_path": project.local_checkout_path,
|
|
"profiles": dict(project.profiles),
|
|
"workflow_paths": dict(project.workflow_paths),
|
|
"schema_paths": dict(project.schema_paths),
|
|
"onboarding_checklist": [
|
|
{"id": step.id, "title": step.title, "description": step.description}
|
|
for step in project.onboarding_checklist
|
|
],
|
|
}
|
|
|
|
|
|
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
|
|
return {
|
|
"version": registry.version,
|
|
"source_path": str(registry.source_path),
|
|
"projects": [project_to_dict(project) for project in registry.projects],
|
|
}
|
|
|
|
|
|
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
|
|
for project in registry.projects:
|
|
if project.id == project_id:
|
|
return project
|
|
return None |