diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index dc8081c..6a348c2 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -79,16 +79,48 @@ async def plane_webhook(request: Request): async def handle_work_item_created(data: dict): """ New work item created in Plane. - 1. Generate work_item_id - 2. Create task in DB - 3. Create branch in Gitea - 4. Create initial docs folder - 5. Set stage to 'analysis' + QG-0: validate title, description, priority. + If valid: create branch, init docs, launch analyst. + If invalid: comment with what's missing, set Blocked. """ plane_id = data.get("id", "") name = data.get("name", "untitled") + description = data.get("description_stripped", data.get("description", "")) + priority = data.get("priority", {}) + priority_name = priority if isinstance(priority, str) else priority.get("name", "") repo = settings.default_repo + # QG-0 validation + errors = [] + if not name or len(name) < 5: + errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + if len(name) > 80: + errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + if not description or len(description.strip()) < 20: + errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + + if errors: + # QG-0 failed + error_text = "\u26a0\ufe0f QG-0 failed:\n" + "\n".join(f"\u2022 {e}" for e in errors) + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID, PLANE_STATES + import httpx as _httpx + # Post comment + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/comments/" + try: + _httpx.post(url, headers=PLANE_HEADERS, + json={"comment_html": f"
{error_text}
"}, timeout=10) + except Exception: + pass + # Set blocked + url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/" + try: + _httpx.patch(url2, headers=PLANE_HEADERS, + json={"state": PLANE_STATES["blocked"]}, timeout=10) + except Exception: + pass + logger.info(f"QG-0 failed for {plane_id}: {errors}") + return + # Generate work item ID work_item_id = get_next_work_item_id(repo) @@ -132,7 +164,7 @@ async def handle_work_item_created(data: dict): logger.info(f"Task {task_id}: launched analyst (run_id={run_id})") # Post start comment to Plane from ..plane_sync import add_comment as _add_comment - _add_comment(work_item_id, "🔍 Analyst запущен. BRD/ТЗ/AC/TestPlan в работе (ожидайте 8-15 мин).") + _add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).") except Exception as e: logger.error(f"Failed to launch analyst for {work_item_id}: {e}") @@ -142,8 +174,8 @@ async def handle_comment(data: dict): Handle comment event — check for :approved: or :rejected:. Advance or rollback stage accordingly. """ - comment_body = data.get("comment", data.get("body", data.get("comment_html", ""))) - plane_id = data.get("work_item_id", data.get("issue_id", "")) + comment_body = data.get("comment_stripped", data.get("comment", data.get("body", data.get("comment_html", "")))) + plane_id = str(data.get("work_item_id") or data.get("issue_id") or data.get("issue") or "") if not plane_id: logger.warning("Comment event without work_item_id, skipping") @@ -161,17 +193,95 @@ async def handle_comment(data: dict): branch = task.get("branch", "") if ":rejected:" in comment_body: - # Rollback to previous stage - prev_stage = get_previous_stage(current_stage) - if prev_stage: - update_task_stage(task_id, prev_stage) - notify_stage_change(task_id, current_stage, prev_stage) - logger.info(f"Task {task_id}: rejected, rolled back {current_stage} → {prev_stage}") + # Extract reason (text after :rejected:) + reason = comment_body.split(":rejected:", 1)[-1].strip()[:300] + + if current_stage == "analysis": + # Already in analysis — just relaunch analyst with rejection reason + from ..plane_sync import set_issue_in_progress + set_issue_in_progress(work_item_id) + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. " + f"Reason: {reason}\nRevise and improve." + ) + new_run = launcher.launch("analyst", repo, task_desc, task_id=task_id) + from ..plane_sync import add_comment as _plane_comment + _plane_comment(work_item_id, f"\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0442\u043a\u043b\u043e\u043d\u0435\u043d\u0438\u044f: {reason}") + logger.info(f"Task {task_id}: rejected at analysis, relaunched analyst") + else: + # Rollback to previous stage + prev_stage = get_previous_stage(current_stage) + if prev_stage: + update_task_stage(task_id, prev_stage) + from ..plane_sync import set_issue_in_progress + set_issue_in_progress(work_item_id) + notify_stage_change(task_id, current_stage, prev_stage) + plane_notify_stage(work_item_id, current_stage, prev_stage) + from ..plane_sync import add_comment as _plane_comment + _plane_comment(work_item_id, f"\U0001f504 \u041e\u0442\u043a\u0430\u0442: {current_stage} \u2192 {prev_stage}. \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {reason}") + logger.info(f"Task {task_id}: rejected, rolled back {current_stage} \u2192 {prev_stage}") return if ":approved:" in comment_body: + from ..plane_sync import set_issue_in_progress + set_issue_in_progress(work_item_id) # Try to advance stage await _try_advance_stage(task_id, current_stage, repo, work_item_id, branch) + return + + # Task 3: If neither :approved: nor :rejected: — check if this is an answer to questions + if current_stage == "analysis": + from ..plane_sync import PLANE_STATES, set_issue_in_progress + issue_id = task.get("plane_issue_id") or task.get("plane_id") + if not issue_id: + issue_id = plane_id + if issue_id: + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID + import httpx as _httpx + try: + _resp = _httpx.get( + f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/", + headers=PLANE_HEADERS, timeout=10 + ) + if _resp.status_code == 200: + issue_data = _resp.json() + if issue_data.get("state") == PLANE_STATES["needs_input"]: + # Task 11: Check analyst retry count (max 3 question rounds) + conn3 = get_db() + analyst_runs = conn3.execute( + "SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='analyst'", + (task_id,) + ).fetchone()[0] + conn3.close() + + if analyst_runs >= 4: # initial + 3 retries + from ..plane_sync import set_issue_blocked, add_comment as _pc + set_issue_blocked(work_item_id) + _pc( + work_item_id, + "\U0001f6a8 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0443\u0442\u043e\u0447\u043d\u0435\u043d\u0438\u0439 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. Analyst \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0441\u0444\u043e\u0440\u043c\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0422\u0417. " + "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0431\u043e\u043b\u0435\u0435 \u0434\u0435\u0442\u0430\u043b\u044c\u043d\u043e\u0435 \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0438\u043b\u0438 \u0432\u0441\u0442\u0440\u0435\u0447\u0430." + ) + from ..notifications import send_telegram + send_telegram(f"\U0001f6a8 {work_item_id}: 3 \u0440\u0430\u0443\u043d\u0434\u0430 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432 analyst'\u0430 \u0438\u0441\u0447\u0435\u0440\u043f\u0430\u043d\u044b. \u041d\u0443\u0436\u043d\u0430 \u043f\u043e\u043c\u043e\u0449\u044c.") + return + + # This is an answer to analyst's questions — relaunch + set_issue_in_progress(work_item_id) + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: analysis\nNote: Stakeholder answered your questions. " + f"Read the latest comment in Plane and revise your artifacts.\n" + f"Answer: {comment_body[:500]}" + ) + new_run = launcher.launch("analyst", repo, task_desc, task_id=task_id) + from ..plane_sync import add_comment as _pc2 + _pc2(work_item_id, "\U0001f504 Analyst \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0449\u0435\u043d \u0441 \u043e\u0442\u0432\u0435\u0442\u0430\u043c\u0438 \u0441\u0442\u0435\u0439\u043a\u0445\u043e\u043b\u0434\u0435\u0440\u0430.") + logger.info(f"Task {task_id}: stakeholder answered questions, relaunched analyst (run_id={new_run})") + return + except Exception as e: + logger.error(f"Failed to check issue state: {e}") async def _try_advance_stage(