From 36d5f25f2a214cd630c25fe64a7b49687c4b566d Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:42 +0300 Subject: [PATCH] feat(projects): add project registry (Plane id -> repo/prefix mapping) ORCH-6: src/projects.py introduces ProjectConfig + resolvers (get_project_by_plane_id/by_repo, known_plane_project_ids) keyed by Plane project uuid. Source: ORCH_PROJECTS_JSON env (config.projects_json), with a built-in default registry (enduro-trails + orchestrator) and robust parsing (malformed JSON/entries fall back to default). --- src/config.py | 5 ++ src/projects.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/projects.py diff --git a/src/config.py b/src/config.py index 07c1fed..d2d7d2c 100644 --- a/src/config.py +++ b/src/config.py @@ -16,6 +16,11 @@ class Settings(BaseSettings): gitea_owner: str = "admin" default_repo: str = "enduro-trails" + # ORCH-6: multi-repo project registry. JSON array of + # {plane_project_id, repo, work_item_prefix, name}. + # Empty -> built-in default registry in src/projects.py. + projects_json: str = "" + # Claude CLI claude_bin: str = "/opt/claude-code/bin/claude.exe" repos_dir: str = "/repos" diff --git a/src/projects.py b/src/projects.py new file mode 100644 index 0000000..3d9f11a --- /dev/null +++ b/src/projects.py @@ -0,0 +1,127 @@ +"""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 + +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 + + +# 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 _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"])), + ) + ) + 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}