232 lines
7.9 KiB
Markdown
232 lines
7.9 KiB
Markdown
# 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: <ORCH_PLANE_API_TOKEN>` (уже в .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": "<state_id>"}
|
||
```
|
||
|
||
**Add comment:**
|
||
```
|
||
POST /workspaces/ag_proj/projects/{project_id}/issues/{issue_id}/comments/
|
||
Body: {"comment_html": "<p>text</p>"}
|
||
```
|
||
|
||
**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"<p>{text}</p>"
|
||
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 что комментарий появился
|
||
```
|