"""ORCH-6: Project registry — map Plane project id -> repo / work-item prefix. Root cause of the 2026-06-02 incident: the Plane webhook listened to the whole workspace and hardcoded ``repo = settings.default_repo`` (enduro-trails). Every issue from any project was funneled into one repo with one prefix (ET). This module introduces a small registry keyed by the Plane project uuid so the orchestrator can: * filter webhooks by project (ignore unknown projects), * resolve the gitea repo + work-item prefix for a known project, * route Plane sync (state/comment) into the issue's own project. Source of truth: ``settings.projects_json`` (a JSON array set via the ``ORCH_PROJECTS_JSON`` env var). If unset/empty/invalid, a built-in default registry is used so the system works out of the box. """ import json import logging from dataclasses import dataclass, field from .config import settings logger = logging.getLogger("orchestrator.projects") @dataclass(frozen=True) class ProjectConfig: plane_project_id: str # uuid of the Plane project (registry key) repo: str # gitea repo name (== folder under /repos) work_item_prefix: str # ET / ORCH name: str # human-readable label # ORCH-41: optional per-project agent->model / agent->effort overrides parsed # from projects_json. frozen dataclass + mutable default -> field(default_factory=dict) # (a bare {} default raises ValueError). Empty dict = no override (old records work). agent_models: dict = field(default_factory=dict) agent_efforts: dict = field(default_factory=dict) # Built-in default registry (used when ORCH_PROJECTS_JSON is empty/invalid). # Keep enduro-trails first so existing behaviour is the safe default. _DEFAULT_PROJECTS = [ ProjectConfig( plane_project_id="7a79f0a9-5278-49cd-9007-9a338f238f9c", repo="enduro-trails", work_item_prefix="ET", name="enduro-trails", ), ProjectConfig( plane_project_id="8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a", repo="orchestrator", work_item_prefix="ORCH", name="orchestrator", ), ] def _coerce_str_map(value, idx, field_name) -> dict: """ORCH-41: coerce an optional projects_json sub-object into a {str: str} dict. Missing / null -> {} (no override). A non-object value is logged and dropped so one malformed entry can never brick the whole registry; non-string keys/values are stringified for safety. """ if value is None: return {} if not isinstance(value, dict): logger.error( f"ORCH_PROJECTS_JSON[{idx}].{field_name} is not an object, ignoring" ) return {} return {str(k): str(v) for k, v in value.items()} def _parse_projects_json(raw: str) -> list[ProjectConfig] | None: """Parse ORCH_PROJECTS_JSON. Returns None if empty/invalid (-> use default).""" if not raw or not raw.strip(): return None try: data = json.loads(raw) except (ValueError, TypeError) as e: logger.error(f"ORCH_PROJECTS_JSON is not valid JSON, falling back to default: {e}") return None if not isinstance(data, list): logger.error("ORCH_PROJECTS_JSON must be a JSON array, falling back to default") return None parsed: list[ProjectConfig] = [] for i, item in enumerate(data): if not isinstance(item, dict): logger.error(f"ORCH_PROJECTS_JSON[{i}] is not an object, skipping") continue try: parsed.append( ProjectConfig( plane_project_id=str(item["plane_project_id"]), repo=str(item["repo"]), work_item_prefix=str(item["work_item_prefix"]), name=str(item.get("name", item["repo"])), agent_models=_coerce_str_map(item.get("agent_models"), i, "agent_models"), agent_efforts=_coerce_str_map(item.get("agent_efforts"), i, "agent_efforts"), ) ) except KeyError as e: logger.error(f"ORCH_PROJECTS_JSON[{i}] missing required key {e}, skipping") continue if not parsed: logger.error("ORCH_PROJECTS_JSON produced no valid entries, falling back to default") return None return parsed def _load_projects() -> list[ProjectConfig]: parsed = _parse_projects_json(getattr(settings, "projects_json", "") or "") if parsed is not None: logger.info(f"Project registry loaded from ORCH_PROJECTS_JSON: {len(parsed)} project(s)") return parsed return list(_DEFAULT_PROJECTS) # Module-level registry, built once at import. PROJECTS: list[ProjectConfig] = _load_projects() _BY_PLANE_ID: dict[str, ProjectConfig] = {p.plane_project_id: p for p in PROJECTS} _BY_REPO: dict[str, ProjectConfig] = {p.repo: p for p in PROJECTS} def get_project_by_plane_id(plane_project_id: str) -> ProjectConfig | None: """Resolve project config by Plane project uuid. None if unknown.""" if not plane_project_id: return None return _BY_PLANE_ID.get(plane_project_id) def get_project_by_repo(repo: str) -> ProjectConfig | None: """Resolve project config by gitea repo name. None if unknown.""" if not repo: return None return _BY_REPO.get(repo) def known_plane_project_ids() -> set[str]: """Set of Plane project ids the orchestrator is configured to handle.""" return set(_BY_PLANE_ID.keys()) def reload_projects() -> None: """Rebuild the registry from current settings (used by tests).""" global PROJECTS, _BY_PLANE_ID, _BY_REPO PROJECTS = _load_projects() _BY_PLANE_ID = {p.plane_project_id: p for p in PROJECTS} _BY_REPO = {p.repo: p for p in PROJECTS}