From fa746105fdd1b5ddb409d1f42f77358352d9e8ea Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Wed, 3 Jun 2026 21:12:38 +0300 Subject: [PATCH] fix(webhook): fetch description from Plane API on status-start Plane issue.updated (status -> In Progress) ships only changed fields, so the webhook payload has no description and QG-0 wrongly blocked issues. start_pipeline now pulls the full description from the Plane issue detail API (reusing the same GET endpoint + shared token as fetch_issue_sequence_id) when the payload field is empty/short, before QG-0 runs. Empty API -> honest QG-0 fail (truly empty ticket). --- src/plane_sync.py | 42 ++++++++++++++++++++++++++++++++++++++++++ src/webhooks/plane.py | 17 +++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/plane_sync.py b/src/plane_sync.py index e96900a..4801db2 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -155,6 +155,48 @@ def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None: return None +import re as _re + + +def _strip_html(html: str) -> str: + """Crude HTML -> text: drop tags and collapse whitespace. Good enough to + feed QG-0's length check when Plane only gives us description_html.""" + if not html: + return "" + text = _re.sub(r"<[^>]+>", " ", html) + return _re.sub(r"\s+", " ", text).strip() + + +def fetch_issue_description(issue_id: str, project_id: str) -> str: + """BUG 1: GET the Plane issue by UUID and return its description text. + + Plane's ``issue.updated`` webhook (e.g. a status change) only carries the + CHANGED fields, so ``description``/``description_stripped`` are usually + absent there. start_pipeline calls this to pull the full description from the + issue detail endpoint so QG-0 does not blow up on an empty payload field. + + Reuses the exact GET issue detail endpoint / shared token already used by + ``fetch_issue_sequence_id`` (same URL, same PLANE_HEADERS). Prefers + ``description_stripped``; falls back to stripping ``description_html``. + + Returns "" on network error, non-2xx, or a missing field - never raises, so + a Plane outage degrades to the honest "empty description" QG-0 path instead + of crashing the webhook. + """ + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" + try: + resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) + resp.raise_for_status() + body = resp.json() + desc = body.get("description_stripped") + if desc and desc.strip(): + return desc + return _strip_html(body.get("description_html") or "") + except Exception as e: + logger.warning(f"fetch_issue_description failed for {issue_id}: {e}") + return "" + + def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: """Find Plane issue UUID by work_item_id (e.g. 'ET-002').""" project_id = _resolve_project_id(work_item_id, project_id) diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 1bd3c94..63b9879 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -261,6 +261,23 @@ async def start_pipeline(data: dict, project_id: str = ""): repo = proj.repo plane_project_id = proj.plane_project_id + # BUG 1: Plane's issue.updated webhook (status change -> In Progress) sends + # only the CHANGED fields, so description / description_stripped are usually + # empty here even though the issue HAS a description. If the payload's + # description is missing/too short, pull the full one from the Plane issue + # detail API (same GET endpoint + shared token already used by + # fetch_issue_sequence_id) before QG-0 runs. If the API is also empty, QG-0 + # legitimately fails (truly empty ticket). + if not description or len(description.strip()) < 20: + from ..plane_sync import fetch_issue_description + fetched = fetch_issue_description(plane_id, plane_project_id) + if fetched and len(fetched.strip()) >= len(description.strip()): + description = fetched + logger.info( + f"start_pipeline: pulled description from Plane API for {plane_id} " + f"({len(description.strip())} chars)" + ) + # QG-0 validation (hard gate on pipeline start) errors = _qg0_errors(name, description) if errors: