# DEV TASK: ORCH-6 — Multi-repo (фильтр проекта + маппинг repo per project) **Статус:** Ready for dev **Проект:** orchestrator **Plane:** ORCH-6 (project id `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a`) **Источник:** `tasks/orchestrator/AUDIT_2026-06-02.md` + ИНЦИДЕНТ 2026-06-02 (webhook auto-run в неправильный репо) **Исполнитель:** Dev-агент (model: tokenator/claude-opus-4-8) **Приоритет:** 🔴 №1 — закрывает корень инцидента и снимает предохранитель (отключённый Plane-webhook) --- ## Контекст инцидента (зачем эта задача) При создании задач ORCH-1..7 в Plane (проект `orchestrator`) Plane-webhook поймал каждую и запустил конвейер — но **всё ушло в репо `enduro-trails`** (`plane.py:91` хардкодит `repo = settings.default_repo`), наплодив мусор ET-010..016. Plane-webhook слушает **весь workspace без фильтра по проекту**. **Сейчас Plane-webhook ДЕАКТИВИРОВАН** (предохранитель, `is_active=false` в Plane postgres, webhook id `93f0c342-a614-4248-9d0f-c107276f5620`). После ORCH-6 его включат обратно. ## Цель Оркестратор должен: 1. **Фильтровать webhook по проекту** — игнорировать issue из неизвестных/неконфигурированных проектов. 2. **Резолвить repo/prefix/Plane-проект из маппинга** по `project` id из payload, а не из единственного `default_repo`. 3. **Plane-sync (state/comment) ходить в ПРАВИЛЬНЫЙ проект** (сейчас `PROJECT_ID` хардкод на enduro). **Критерий приёмки:** оркестратор корректно ведёт задачу в проекте `orchestrator` (repo `orchestrator`, prefix `ORCH`, свой Plane-проект) и в `enduro-trails` (repo `enduro-trails`, prefix `ET`) — не путая их. --- ## Инфраструктура | Параметр | Значение | |----------|----------| | Сервер | `slin@82.22.50.71` (SSH key) | | Репо орка | `/home/slin/repos/orchestrator/` (remote `admin/orchestrator`) | | Контейнер | `orchestrator` (port 8500, network host) | | Health | `curl -s http://localhost:8500/health` | | Тесты | `IMG=$(docker inspect orchestrator --format '{{.Config.Image}}'); docker run --rm -v /home/slin/repos/orchestrator:/code -w /code --entrypoint python3 $IMG -m pytest tests/ -q` | | Plane API | `http://localhost:8091/api/v1`, token из `ORCH_PLANE_API_TOKEN` | | Plane workspace | `ag_proj` (slug), workspace uuid `903e12e8-65c9-40a0-a7f6-0186f8af42d4` | ⚠️ Хостовый `.venv` сломан — тесты ТОЛЬКО через образ. ### Известные id (для маппинга по умолчанию) | Проект | Plane project id | repo (gitea) | prefix | |--------|------------------|--------------|--------| | enduro-trails | `7a79f0a9-5278-49cd-9007-9a338f238f9c` | `enduro-trails` | `ET` | | orchestrator | `8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a` | `orchestrator` | `ORCH` | --- ## Корневые точки в коде (собрано Стрим — не ищи вслепую) | Файл:строка | Сейчас | Проблема | |-------------|--------|----------| | `src/webhooks/plane.py:91` | `repo = settings.default_repo` | хардкод, игнорит `data.project` | | `src/webhooks/plane.py:125` | `get_next_work_item_id(repo)` | prefix `ET` хардкод в db.py | | `src/webhooks/plane.py:105,240` | импорт `PROJECT_ID` из plane_sync | хардкод на enduro | | `src/plane_sync.py:12` | `PROJECT_ID = settings.plane_project_id or "7a79f0a9..."` | хардкод; все sync-URL (строки 55,97,113,150) на единственный проект | | `src/qg/checks.py:177-182` | импорт+использование `PROJECT_ID` | хардкод | | `src/db.py:82` `get_next_work_item_id` | `prefix = "ET"` если нет записей | prefix не зависит от проекта | | `src/webhooks/gitea.py:85,148,169,219` | `repo = payload["repository"]["name"]` (fallback default) | уже почти ок, repo из payload | | `src/config.py` | плоский, один `default_repo`, один `plane_project_id` | нет маппинга | | payload webhook | `data.project` = Plane project uuid | **есть** чем фильтровать ✅ | --- ## Решение (архитектура) Ввести **реестр проектов** — список конфигов, ключ = Plane project id. ### Структура ```python # src/projects.py (НОВЫЙ модуль) @dataclass class ProjectConfig: plane_project_id: str # uuid Plane-проекта (ключ маппинга) repo: str # имя gitea-репо (= папка в /repos) work_item_prefix: str # ET / ORCH name: str # человекочитаемое # источник: settings.projects_json (JSON в .env) ИЛИ дефолт-список в коде ``` **Конфиг через .env (НЕ трогать секреты, добавить новый ключ):** `ORCH_PROJECTS_JSON` — JSON-массив. Если пусто — дефолт = два проекта из таблицы выше (enduro-trails + orchestrator), чтобы система работала из коробки. ### Резолверы ```python def get_project_by_plane_id(plane_project_id) -> ProjectConfig | None def get_project_by_repo(repo) -> ProjectConfig | None def known_plane_project_ids() -> set[str] ``` --- ## Задачи ### Task 1: контекст + модуль реестра - [ ] **1.1** Прочитай `AUDIT_2026-06-02.md`, текущие `plane.py`, `plane_sync.py`, `config.py`, `db.py`. Пойми все точки из таблицы выше. - [ ] **1.2** Работай в ветке `feature/ORCH-6-multirepo` репо orchestrator. `git status` чистый. - [ ] **1.3** Создать `src/projects.py` с `ProjectConfig`, дефолт-списком (enduro-trails + orchestrator из таблицы) и резолверами. Источник переопределения — `settings.projects_json` (если задан и валиден). - [ ] **1.4** В `config.py` добавить: ```python projects_json: str = "" # ORCH-6: JSON-массив ProjectConfig; пусто = дефолт в projects.py ``` **Критерий:** `get_project_by_plane_id("8da6aa25-...")` → orchestrator; `get_project_by_repo("enduro-trails")` → ET. --- ### Task 2: фильтр + резолв в plane-webhook **Файл:** `src/webhooks/plane.py` - [ ] **2.1** В `plane_webhook()` (или в `handle_work_item_created`/`handle_comment`): извлечь `project_id = data.get("project")`. Если `project_id` НЕ в `known_plane_project_ids()` — **залогировать и проигнорировать** (`return {"status":"ignored","reason":"unknown project"}`). Это и есть фильтр, который предотвратит инцидент. - [ ] **2.2** `plane.py:91`: вместо `repo = settings.default_repo` → ```python proj = get_project_by_plane_id(project_id) repo = proj.repo ``` - [ ] **2.3** `get_next_work_item_id(repo)` — prefix должен браться из `proj.work_item_prefix` (см. Task 4). - [ ] **2.4** Все локальные использования `PROJECT_ID` (строки ~105-117, ~240-244) — заменить на `proj.plane_project_id` (Plane-sync для ЭТОЙ задачи должен идти в её проект). Передавай project_id дальше в helper'ы. **Критерий:** issue из проекта orchestrator → repo `orchestrator`, sync в проект orchestrator; неизвестный проект → ignored. --- ### Task 3: plane_sync — параметризовать проект **Файл:** `src/plane_sync.py` - [ ] **3.1** Функции (`notify_stage_change`, `notify_qg_failure`, `notify_done`, `find_issue_id`, и др.), которые строят URL с `PROJECT_ID` — должны принимать `project_id` параметром (с дефолтом на enduro для обратной совместимости, ЧТОБЫ не сломать существующие вызовы). Резолвить project_id из task (по repo задачи через `get_project_by_repo`). - [ ] **3.2** Вызовы из `plane.py` и `qg/checks.py` — передавать правильный project_id (из задачи/проекта). ⚠️ Если task знает только `repo` — резолвь `get_project_by_repo(repo).plane_project_id`. **Критерий:** state/comment задачи orchestrator пишутся в Plane-проект orchestrator, а не в enduro. --- ### Task 4: work_item prefix per project **Файл:** `src/db.py` (`get_next_work_item_id`) - [ ] **4.1** Сделать prefix параметром: `get_next_work_item_id(repo, prefix="ET")`. Нумерация — по последней записи ЭТОГО repo+prefix. Вызов из plane.py передаёт `proj.work_item_prefix`. - [ ] **4.2** Убедиться, что фильтр `WHERE repo=?` корректен (orchestrator-задачи нумеруются ORCH-001, ORCH-002 независимо от ET). **Критерий:** задача в orchestrator → `ORCH-001`; в enduro → `ET-010` (продолжая существующую нумерацию). --- ### Task 5: gitea-webhook (минимально) **Файл:** `src/webhooks/gitea.py` - [ ] **5.1** Уже берёт `repo` из `payload.repository.name` — проверить, что для repo вне реестра — игнор (по аналогии с Task 2.1: `get_project_by_repo(repo)` None → ignored). Не запускать стадии для неизвестного repo. **Критерий:** push в неизвестный repo не триггерит конвейер. --- ### Task 6: тесты + документация - [ ] **6.1** `tests/test_projects.py`: резолверы (by plane_id, by repo, unknown → None, known_plane_project_ids), парсинг `projects_json`. - [ ] **6.2** `tests/test_plane_webhook.py` (или дополнить существующий): webhook из неизвестного проекта → ignored; из orchestrator → repo=orchestrator; из enduro → repo=enduro-trails. Мокать launcher.launch чтобы не запускать реальных агентов. - [ ] **6.3** Прогнать ВСЕ тесты (команда из Инфраструктуры). `test_webhooks.py` 9 pre-existing падений (401/signature) — не твои, не сломай остальное. - [ ] **6.4** Обновить `docs/ARCHITECTURE.md` (раздел multi-repo, реестр проектов), `README` (как добавить новый проект через `ORCH_PROJECTS_JSON`). Создать `docs/BUGFIXES_2026-06-03.md` (или дату прогона) с описанием ORCH-6 + ссылкой на инцидент. **Критерий:** тесты зелёные, доки описывают как заводить новый проект. --- ### Task 7: деплой + проверка (БЕЗ включения webhook!) - [ ] **7.1** Коммиты (Conventional Commits), push в `feature/ORCH-6-multirepo`, PR в orchestrator. - [ ] **7.2** Пересобрать: `cd /home/slin/repos/orchestrator && docker compose up -d --build && sleep 5 && curl -s http://localhost:8500/health`. - [ ] **7.3** **Тест резолва (offline, без реальных запусков):** ```bash docker exec orchestrator python3 -c " import sys; sys.path.insert(0,'/app') from src.projects import get_project_by_plane_id, get_project_by_repo, known_plane_project_ids o = get_project_by_plane_id('8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a') e = get_project_by_plane_id('7a79f0a9-5278-49cd-9007-9a338f238f9c') assert o.repo=='orchestrator' and o.work_item_prefix=='ORCH', o assert e.repo=='enduro-trails' and e.work_item_prefix=='ET', e assert get_project_by_plane_id('00000000-0000-0000-0000-000000000000') is None print('RESOLVE OK:', o.repo, e.repo, '| known:', len(known_plane_project_ids())) " ``` - [ ] **7.4** **Тест фильтра (симуляция webhook неизвестного проекта)** — отправить POST /webhook/plane с `data.project` = фейковый uuid, убедиться что ответ `ignored` и НЕ создаётся task/ветка. (Можно через pytest с TestClient — предпочтительно, чтобы не дёргать живой эндпоинт.) - [ ] **7.5** ⚠️ **НЕ включать Plane-webhook** (is_active остаётся false). Включение — отдельный шаг Стрим после ревью. - [ ] **7.6** Отчитаться Стрим: резолв + фильтр подтверждены, готово к включению webhook. **Критерий:** резолв корректен, неизвестный проект игнорируется, webhook остаётся выключенным. --- ## Acceptance (что проверит Стрим) | # | Проверка | Ожидаемо | |---|----------|----------| | 1 | резолв by plane_id | orchestrator→ORCH/repo, enduro→ET/repo | | 2 | неизвестный проект | None / ignored | | 3 | webhook из orchestrator-проекта | repo=orchestrator, sync в его Plane-проект | | 4 | prefix per project | ORCH-001 vs ET-010 | | 5 | gitea неизвестный repo | ignored | | 6 | тесты | all pass (кроме 9 pre-existing) | | 7 | webhook is_active | остаётся false (Стрим включит после ревью) | | 8 | доки | multi-repo + как добавить проект | --- ## Ограничения - 🚫 **НЕ трогай:** nginx, openclaw.json, .env-СЕКРЕТЫ (можно добавить НОВЫЙ ключ `ORCH_PROJECTS_JSON`, но не трогать существующие токены), deploy-хук. - 🚫 **НЕ включай Plane-webhook** (`is_active`) — это сделает Стрим после ревью. - ⚠️ **Обратная совместимость plane_sync:** существующие вызовы без project_id должны продолжать работать (дефолт на enduro). Не сломай ET-009/текущие задачи. - ⚠️ **Не ломай ORCH-2 (worktree)**, B-1/B-2/S-1/S-5 фиксы. - 🚫 Per-project deploy-хуки и per-project агентские инструкции — в этой задаче НЕ обязательны (можно вынести в подзадачу). Минимум для снятия предохранителя = фильтр проекта + resolve repo/prefix + plane_sync в правильный проект. - 🚫 Авто-clone нового repo — НЕ в этой задаче (оба repo уже в /repos). ## Деплой-чеклист - [ ] `src/projects.py` + реестр + резолверы - [ ] plane-webhook фильтр по проекту + resolve repo - [ ] plane_sync параметризован по project_id (обратно совместимо) - [ ] prefix per project в db.py - [ ] gitea неизвестный repo → ignored - [ ] тесты зелёные (кроме pre-existing) - [ ] орк пересобран, health ok - [ ] резолв + фильтр подтверждены - [ ] webhook ОСТАЁТСЯ выключенным - [ ] доки + BUGFIXES - [ ] отчёт Стрим --- *Создано: 2026-06-02 | Автор ТЗ: Стрим | Исполнитель: Dev (Opus 4.8 Tokenator)*