diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 07ee6c5..177ce6e 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -24,6 +24,11 @@ from ..plane_sync import ( notify_qg_failure as plane_notify_qg, notify_done as plane_notify_done, ) +from ..projects import ( + get_project_by_plane_id, + get_project_by_repo, + known_plane_project_ids, +) logger = logging.getLogger("orchestrator.webhooks.plane") @@ -68,15 +73,26 @@ async def plane_webhook(request: Request): action = payload.get("action", "") data = payload.get("data", {}) + # ORCH-6: filter by Plane project. Ignore issues from unknown/unconfigured + # projects so a webhook on the whole workspace cannot funnel everything into + # the default repo (root cause of the 2026-06-02 incident). + project_id = data.get("project") or data.get("project_id") or "" + if project_id not in known_plane_project_ids(): + logger.info( + f"Plane webhook: ignoring event '{event}' from unknown project " + f"'{project_id}' (known: {len(known_plane_project_ids())})" + ) + return {"status": "ignored", "reason": "unknown project"} + if (event == "work_item.created") or (event == "issue" and action == "created"): - await handle_work_item_created(data) + await handle_work_item_created(data, project_id) elif (event == "comment.created") or (event == "issue_comment" and action == "created"): - await handle_comment(data) + await handle_comment(data, project_id) return {"status": "accepted"} -async def handle_work_item_created(data: dict): +async def handle_work_item_created(data: dict, project_id: str = ""): """ New work item created in Plane. QG-0: validate title, description, priority. @@ -88,7 +104,17 @@ async def handle_work_item_created(data: dict): 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 + + # ORCH-6: resolve repo / prefix / Plane project from the registry instead of + # the single hardcoded default_repo. + if not project_id: + project_id = data.get("project") or data.get("project_id") or "" + proj = get_project_by_plane_id(project_id) + if not proj: + logger.warning(f"handle_work_item_created: unknown project '{project_id}', ignoring {plane_id}") + return + repo = proj.repo + plane_project_id = proj.plane_project_id # QG-0 validation errors = [] @@ -102,17 +128,17 @@ async def handle_work_item_created(data: dict): 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 + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PLANE_STATES import httpx as _httpx - # Post comment - url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{plane_id}/comments/" + # Post comment (ORCH-6: route to the issue's own project) + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_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}/" + url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/" try: _httpx.patch(url2, headers=PLANE_HEADERS, json={"state": PLANE_STATES["blocked"]}, timeout=10) @@ -122,7 +148,7 @@ async def handle_work_item_created(data: dict): return # Generate work item ID - work_item_id = get_next_work_item_id(repo) + work_item_id = get_next_work_item_id(repo, proj.work_item_prefix) # Create slug from name slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30] @@ -169,7 +195,7 @@ async def handle_work_item_created(data: dict): logger.error(f"Failed to launch analyst for {work_item_id}: {e}") -async def handle_comment(data: dict): +async def handle_comment(data: dict, project_id: str = ""): """ Handle comment event — check for :approved: or :rejected:. Advance or rollback stage accordingly. @@ -237,11 +263,15 @@ async def handle_comment(data: dict): if not issue_id: issue_id = plane_id if issue_id: - from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, PROJECT_ID + from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE + from ..plane_sync import PROJECT_ID as _DEFAULT_PROJECT_ID + # ORCH-6: route to this task's own Plane project (resolved from repo). + _proj = get_project_by_repo(repo) + _pid = _proj.plane_project_id if _proj else (project_id or _DEFAULT_PROJECT_ID) import httpx as _httpx try: _resp = _httpx.get( - f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{PROJECT_ID}/issues/{issue_id}/", + f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{_pid}/issues/{issue_id}/", headers=PLANE_HEADERS, timeout=10 ) if _resp.status_code == 200: