# DEV_TASK_PLANE_SYNC.md — Обратная связь Orchestrator → Plane --- type: dev-task priority: high project: multi-agent created_at: 2026-05-21 author: stream --- ## Контекст Orchestrator получает webhooks из Plane, но не пишет обратно. Слава не видит прогресс задач в Plane. Нужно добавить sync: при смене stage → обновлять state issue, добавлять комментарии. ## Plane API **Base URL:** `http://localhost:8091/api/v1` **Auth:** Header `X-API-Key: ` (уже в .env) **Workspace:** `ag_proj` **Project ID:** `7a79f0a9-5278-49cd-9007-9a338f238f9c` ### States (project "Enduro Trails") | ID | Name | Group | |----|------|-------| | `113b24f6-cce8-4be9-9a22-a359b9cf0122` | Backlog | backlog | | `2c7d3df3-9eb9-419b-92b7-d7d560bcdd10` | Todo | unstarted | | `b873d9eb-993c-48cd-97ac-99a9b1623967` | In Progress | started | | `381a2833-3c4e-4be5-bd0f-be84cb946ad8` | Done | completed | | `b1cae7f9-961d-4889-a179-f3acea697d17` | Cancelled | cancelled | ### API Endpoints **Update issue state:** ``` PATCH /workspaces/ag_proj/projects/{project_id}/issues/{issue_id}/ Body: {"state": ""} ``` **Add comment:** ``` POST /workspaces/ag_proj/projects/{project_id}/issues/{issue_id}/comments/ Body: {"comment_html": "

text

"} ``` **Get issue by identifier (sequence_id):** ``` GET /workspaces/ag_proj/projects/{project_id}/issues/?search=ET-002 ``` ## Задачи ### 1. Создать модуль `src/plane_sync.py` ```python """Plane API sync — update issue state and add comments.""" import logging import httpx from .config import settings logger = logging.getLogger("orchestrator.plane_sync") PLANE_BASE = f"{settings.plane_api_url}/api/v1" PLANE_HEADERS = {"X-API-Key": settings.plane_api_token} WORKSPACE = settings.plane_workspace_slug PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" # Map orchestrator stages to Plane states STAGE_TO_STATE = { "created": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10", # Todo "analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "architecture": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "development": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "review": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "testing": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "deploy": "b873d9eb-993c-48cd-97ac-99a9b1623967", # In Progress "done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # Done } def find_issue_id(work_item_id: str) -> str | None: """Find Plane issue UUID by work_item_id (e.g. 'ET-002').""" url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/" try: resp = httpx.get(url, headers=PLANE_HEADERS, params={"search": work_item_id}, timeout=10) resp.raise_for_status() data = resp.json() results = data.get("results", data if isinstance(data, list) else []) for issue in results: # Match by sequence_id or name containing work_item_id seq = issue.get("sequence_id") identifier = f"ET-{seq}" if seq else "" if identifier == work_item_id or work_item_id in issue.get("name", ""): return issue["id"] except Exception as e: logger.error(f"Failed to find issue for {work_item_id}: {e}") return None def update_issue_state(work_item_id: str, stage: str): """Update Plane issue state based on orchestrator stage.""" state_id = STAGE_TO_STATE.get(stage) if not state_id: return issue_id = find_issue_id(work_item_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}") return url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/" try: resp = httpx.patch(url, headers=PLANE_HEADERS, json={"state": state_id}, timeout=10) resp.raise_for_status() logger.info(f"Plane: {work_item_id} state → {stage} ({state_id[:8]}...)") except Exception as e: logger.error(f"Failed to update Plane state for {work_item_id}: {e}") def add_comment(work_item_id: str, text: str): """Add a comment to Plane issue.""" issue_id = find_issue_id(work_item_id) if not issue_id: logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment") return url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/comments/" html = f"

{text}

" try: resp = httpx.post(url, headers=PLANE_HEADERS, json={"comment_html": html}, timeout=10) resp.raise_for_status() logger.info(f"Plane: comment added to {work_item_id}") except Exception as e: logger.error(f"Failed to add comment to {work_item_id}: {e}") def notify_stage_change(work_item_id: str, old_stage: str, new_stage: str, agent: str = None): """Notify Plane about stage transition.""" update_issue_state(work_item_id, new_stage) msg = f"🔄 Stage: {old_stage} → {new_stage}" if agent: msg += f" (launching {agent})" add_comment(work_item_id, msg) def notify_qg_failure(work_item_id: str, stage: str, check: str, reason: str): """Notify Plane about QG failure.""" add_comment(work_item_id, f"⚠️ QG failed at {stage}: {check} — {reason}") def notify_done(work_item_id: str): """Mark issue as Done in Plane.""" update_issue_state(work_item_id, "done") add_comment(work_item_id, "✅ Task completed! PR merged and deployed.") ``` ### 2. Интегрировать в webhooks/plane.py В функции `_try_advance_stage`, после `update_task_stage(task_id, next_stage)`: ```python from ..plane_sync import notify_stage_change as plane_notify_stage, notify_qg_failure as plane_notify_qg # После успешного advance: plane_notify_stage(work_item_id, current_stage, next_stage, agent) # После QG failure: plane_notify_qg(work_item_id, current_stage, qg_name, reason) ``` ### 3. Интегрировать в webhooks/gitea.py В `handle_ci_status` и `handle_pr_review`, после advance: ```python from ..plane_sync import notify_stage_change as plane_notify_stage # После advance: plane_notify_stage(work_item_id, current_stage, next_stage, agent) ``` ### 4. Добавить config fields **Файл:** `/home/slin/repos/orchestrator/src/config.py` Добавить (если ещё нет): ```python plane_api_url: str = os.getenv("ORCH_PLANE_API_URL", "http://localhost:8091") plane_api_token: str = os.getenv("ORCH_PLANE_API_TOKEN", "") plane_workspace_slug: str = os.getenv("ORCH_PLANE_WORKSPACE_SLUG", "ag_proj") ``` --- ## Файлы для изменения - `/home/slin/repos/orchestrator/src/plane_sync.py` (НОВЫЙ) - `/home/slin/repos/orchestrator/src/webhooks/plane.py` (добавить вызовы) - `/home/slin/repos/orchestrator/src/webhooks/gitea.py` (добавить вызовы) - `/home/slin/repos/orchestrator/src/config.py` (проверить что plane fields есть) ## Ограничения - НЕ менять порт 8500 - НЕ менять формат .env (все переменные уже есть) - НЕ блокировать основной flow если Plane API недоступен (try/except, log error, continue) - httpx уже в requirements.txt ## Проверка ```bash # 1. Rebuild cd /home/slin/repos/orchestrator && docker compose up -d --build # 2. Health curl -s http://localhost:8500/health # 3. Smoke test — вызвать plane_sync напрямую docker exec orchestrator python -c " from src.plane_sync import find_issue_id, add_comment issue_id = find_issue_id('ET-002') print(f'Found issue: {issue_id}') if issue_id: add_comment('ET-002', '🧪 Test comment from Orchestrator') print('Comment added!') " # 4. Проверить в Plane UI что комментарий появился ```