fix(plane): resolve issue states per-project instead of hardcoded enduro UUIDs (ORCH-10)
All checks were successful
CI / test (push) Successful in 12s
CI / test (pull_request) Successful in 10s

ORCH-10 root cause: PLANE_STATES was a global dict hardcoding enduro-trails
UUIDs. The webhook comparison  only
matched ET UUID (b873d9eb) and silently ignored the ORCH in_progress UUID
(e331bfb3), blocking pipeline start for all orchestrator-project tasks.

Changes:
- src/plane_sync.py:
  * Rename PLANE_STATES -> _DEFAULT_STATES (enduro UUIDs kept as safe fallback).
  * PLANE_STATES preserved as alias to _DEFAULT_STATES (backward compat).
  * Add get_project_states(project_id) -> {logical_key: state_uuid}:
    fetches Plane API GET /projects/<id>/states/, maps by state name,
    caches per project_id, falls back to _DEFAULT_STATES on API failure.
  * Add _STATES_CACHE: dict, reload_project_states(project_id=None).
  * Add _PLANE_NAME_TO_KEY mapping and _STAGE_TO_STATE_KEY for clean lookup.
  * Add stage_to_state(stage, project_id) using get_project_states().
  * update_issue_state() uses stage_to_state() instead of STAGE_TO_STATE dict.
  * set_issue_{needs_input,in_review,blocked,done,in_progress,stage_state}()
    all resolve state UUID via get_project_states(project_id) instead of
    the global PLANE_STATES dict.

- src/webhooks/plane.py:
  * handle_issue_updated: import get_project_states, resolve proj_states per
    incoming project_id, compare new_state against proj_states["in_progress"],
    proj_states["approved"], proj_states["rejected"].
  * start_pipeline QG-0 blocked path: use get_project_states(plane_project_id)
    instead of PLANE_STATES["blocked"].

- tests/test_orch10_states.py: 23 new tests covering:
  * get_project_states returns correct UUIDs for both ET and ORCH projects.
  * API failure / empty response / None project_id -> _DEFAULT_STATES fallback.
  * Caching and reload_project_states (per-project and full flush).
  * stage_to_state() per-project resolution.
  * Webhook in_progress triggers pipeline for BOTH b873d9eb (ET) and e331bfb3 (ORCH).
  * Webhook approved/rejected routes correctly per project.
  * PLANE_STATES alias and _DEFAULT_STATES backward compat.
This commit is contained in:
Dev Agent
2026-06-05 14:23:31 +03:00
parent 5ecd1c4692
commit 00325bcab0
3 changed files with 653 additions and 50 deletions

View File

@@ -137,7 +137,7 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
Any other status (Needs Input, In Review, Blocked, Done, board stages, etc.)
is ignored here — those are statuses the orchestrator itself sets.
"""
from ..plane_sync import PLANE_STATES
from ..plane_sync import get_project_states
plane_id = str(data.get("id") or "")
new_state = _state_id(data)
@@ -145,11 +145,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
logger.info("issue updated without id/state, ignoring")
return
if new_state == PLANE_STATES["in_progress"]:
# ORCH-10: resolve expected state UUIDs per the incoming issue's project so
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
proj_states = get_project_states(project_id)
if new_state == proj_states["in_progress"]:
await handle_status_start(data, project_id)
elif new_state == PLANE_STATES["approved"]:
elif new_state == proj_states["approved"]:
await handle_verdict(data, project_id, approved=True)
elif new_state == PLANE_STATES["rejected"]:
elif new_state == proj_states["rejected"]:
await handle_verdict(data, project_id, approved=False)
else:
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
@@ -422,7 +426,7 @@ async def start_pipeline(data: dict, project_id: str = ""):
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, PLANE_STATES
from ..plane_sync import PLANE_BASE, PLANE_HEADERS, WORKSPACE, get_project_states
import httpx as _httpx
# 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/"
@@ -431,11 +435,12 @@ async def start_pipeline(data: dict, project_id: str = ""):
json={"comment_html": f"<p>{error_text}</p>"}, timeout=10)
except Exception:
pass
# Set blocked
# Set blocked — ORCH-10: resolve per-project UUID.
url2 = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{plane_project_id}/issues/{plane_id}/"
try:
_blocked = get_project_states(plane_project_id)["blocked"]
_httpx.patch(url2, headers=PLANE_HEADERS,
json={"state": PLANE_STATES["blocked"]}, timeout=10)
json={"state": _blocked}, timeout=10)
except Exception:
pass
logger.info(f"QG-0 failed for {plane_id}: {errors}")