fix(webhooks/plane): filter by project + resolve repo/prefix from registry

ORCH-6 / incident 2026-06-02: ignore work items from unknown Plane
projects (status=ignored) instead of funneling everything into
default_repo. Resolve repo, work-item prefix and Plane sync project from
the registry by data.project.
This commit is contained in:
Dev Agent
2026-06-02 22:30:42 +03:00
parent a87c633003
commit 171f4eb304

View File

@@ -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"<p>{error_text}</p>"}, 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: