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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user