fix(plane): resolve issue states per-project instead of hardcoded enduro UUIDs (ORCH-10)
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:
@@ -84,31 +84,131 @@ def _resolve_project_id(work_item_id: str = None, project_id: str = None) -> str
|
||||
logger.debug(f"_resolve_project_id fallback for {work_item_id}: {e}")
|
||||
return PROJECT_ID
|
||||
|
||||
# Plane state IDs.
|
||||
# TODO(ORCH-10): these UUIDs are PER-PROJECT. The 6 stage-visibility / verdict
|
||||
# statuses below were created only in the enduro project (7a79f0a9-...). One
|
||||
# project is in prod today, so a single global dict is acceptable. When more
|
||||
# projects are onboarded these must be resolved per project (see ORCH-10 in
|
||||
# BACKLOG.md / the ORCH-6 project registry) — do NOT hardcode globally then.
|
||||
PLANE_STATES = {
|
||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
# ORCH-10: per-project state resolution.
|
||||
#
|
||||
# _DEFAULT_STATES keeps the original enduro-trails UUIDs as a safe fallback
|
||||
# (used when the Plane API is unreachable and for backward compat).
|
||||
# PLANE_STATES is preserved as an alias so existing call sites that reference
|
||||
# it directly (QG-0 fast-path in webhooks/plane.py, tests) continue to work.
|
||||
_DEFAULT_STATES = {
|
||||
"backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122",
|
||||
"todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10",
|
||||
"in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac",
|
||||
"in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b",
|
||||
"blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920",
|
||||
"done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8",
|
||||
"cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17",
|
||||
# Feature 3 (stage visibility) — per-stage statuses on the board.
|
||||
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
|
||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||
"development": "9920609b-f140-4e46-ab95-89acda8412c8",
|
||||
"review": "ba0d802c-5218-41d4-ab43-978b0ea123ed",
|
||||
"testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02",
|
||||
# Feature 2 (verdict statuses) — Approved / Rejected.
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
"approved": "a519a341-dada-4a91-8910-7604f82b79c5",
|
||||
"rejected": "ba958f3c-5db5-461d-8f82-89425e413b97",
|
||||
}
|
||||
|
||||
# Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it).
|
||||
PLANE_STATES = _DEFAULT_STATES
|
||||
|
||||
# Mapping: Plane state *name* (as returned by the API) -> logical key.
|
||||
_PLANE_NAME_TO_KEY: dict[str, str] = {
|
||||
"Backlog": "backlog",
|
||||
"Todo": "todo",
|
||||
"In Progress": "in_progress",
|
||||
"Architecture": "architecture",
|
||||
"Development": "development",
|
||||
"Review": "review",
|
||||
"Testing": "testing",
|
||||
"Approved": "approved",
|
||||
"Rejected": "rejected",
|
||||
"Done": "done",
|
||||
"Cancelled": "cancelled",
|
||||
"Needs Input": "needs_input",
|
||||
"In Review": "in_review",
|
||||
"Blocked": "blocked",
|
||||
}
|
||||
|
||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||
_STATES_CACHE: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
def get_project_states(project_id: str) -> dict[str, str]:
|
||||
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
|
||||
|
||||
Source of truth: Plane API GET /projects/<project_id>/states/.
|
||||
Results are cached per project_id for the lifetime of the process.
|
||||
Falls back to _DEFAULT_STATES (enduro-trails values) if:
|
||||
* project_id is empty/None,
|
||||
* the API call fails (network error, non-2xx),
|
||||
* the response contains no recognisable states.
|
||||
|
||||
The enduro-trails project therefore returns the same UUIDs as before
|
||||
(backward compatible). The orchestrator project returns its own UUIDs,
|
||||
fixing the ORCH-10 blocker.
|
||||
"""
|
||||
if not project_id:
|
||||
return _DEFAULT_STATES
|
||||
|
||||
if project_id in _STATES_CACHE:
|
||||
return _STATES_CACHE[project_id]
|
||||
|
||||
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
|
||||
try:
|
||||
resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
# Plane returns {"results": [...]} or a bare list.
|
||||
items = body.get("results", body) if isinstance(body, dict) else body
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"unexpected states response shape: {type(items)}")
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
for item in items:
|
||||
name = item.get("name", "")
|
||||
uid = item.get("id", "")
|
||||
key = _PLANE_NAME_TO_KEY.get(name)
|
||||
if key and uid:
|
||||
resolved[key] = uid
|
||||
|
||||
if not resolved:
|
||||
raise ValueError("no recognisable states in API response")
|
||||
|
||||
# Fill any missing keys from _DEFAULT_STATES so callers always get a
|
||||
# complete mapping (defensive against partial Plane configs).
|
||||
for k, v in _DEFAULT_STATES.items():
|
||||
resolved.setdefault(k, v)
|
||||
|
||||
_STATES_CACHE[project_id] = resolved
|
||||
logger.debug(
|
||||
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
|
||||
)
|
||||
return resolved
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"get_project_states: API failed for project {project_id[:8]}..., "
|
||||
f"falling back to _DEFAULT_STATES. Error: {e}"
|
||||
)
|
||||
return _DEFAULT_STATES
|
||||
|
||||
|
||||
def reload_project_states(project_id: str = None) -> None:
|
||||
"""ORCH-10: clear the per-project states cache.
|
||||
|
||||
If project_id is given, evict only that project.
|
||||
If None, flush the entire cache (useful in tests and after config reload).
|
||||
"""
|
||||
global _STATES_CACHE
|
||||
if project_id is None:
|
||||
_STATES_CACHE = {}
|
||||
logger.debug("reload_project_states: full cache cleared")
|
||||
else:
|
||||
_STATES_CACHE.pop(project_id, None)
|
||||
logger.debug(f"reload_project_states: evicted project {project_id[:8]}...")
|
||||
|
||||
|
||||
# Feature 3: map an orchestrator stage -> the Plane status to show on the board
|
||||
# when the pipeline ENTERS that stage. analysis stays driven by the existing
|
||||
# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps
|
||||
@@ -121,21 +221,44 @@ STAGE_VISIBILITY_STATE = {
|
||||
"testing": "testing",
|
||||
}
|
||||
|
||||
# Map orchestrator stages to Plane states (used by update_issue_state /
|
||||
# notify_stage_change). Feature 3: architecture/development/review/testing now
|
||||
# point at their dedicated board statuses so the task physically moves across
|
||||
# columns. analysis -> in_progress, deploy -> in_progress, done -> done.
|
||||
# STAGE_TO_STATE kept for backward compat (used by tests that patch it).
|
||||
# update_issue_state now calls stage_to_state() instead of looking up here.
|
||||
STAGE_TO_STATE = {
|
||||
"created": PLANE_STATES["todo"],
|
||||
"analysis": PLANE_STATES["in_progress"],
|
||||
"architecture": PLANE_STATES["architecture"],
|
||||
"development": PLANE_STATES["development"],
|
||||
"review": PLANE_STATES["review"],
|
||||
"testing": PLANE_STATES["testing"],
|
||||
"deploy": PLANE_STATES["in_progress"],
|
||||
"done": PLANE_STATES["done"],
|
||||
"created": _DEFAULT_STATES["todo"],
|
||||
"analysis": _DEFAULT_STATES["in_progress"],
|
||||
"architecture": _DEFAULT_STATES["architecture"],
|
||||
"development": _DEFAULT_STATES["development"],
|
||||
"review": _DEFAULT_STATES["review"],
|
||||
"testing": _DEFAULT_STATES["testing"],
|
||||
"deploy": _DEFAULT_STATES["in_progress"],
|
||||
"done": _DEFAULT_STATES["done"],
|
||||
}
|
||||
|
||||
# Map orchestrator stage -> logical state key (project-independent).
|
||||
_STAGE_TO_STATE_KEY = {
|
||||
"created": "todo",
|
||||
"analysis": "in_progress",
|
||||
"architecture": "architecture",
|
||||
"development": "development",
|
||||
"review": "review",
|
||||
"testing": "testing",
|
||||
"deploy": "in_progress",
|
||||
"done": "done",
|
||||
}
|
||||
|
||||
|
||||
def stage_to_state(stage: str, project_id: str) -> str | None:
|
||||
"""ORCH-10: return the Plane state UUID for a pipeline stage in a project.
|
||||
|
||||
Resolves via get_project_states so the correct per-project UUID is used.
|
||||
Returns None for unknown stages (same behaviour as the old STAGE_TO_STATE
|
||||
dict lookup returning None).
|
||||
"""
|
||||
key = _STAGE_TO_STATE_KEY.get(stage)
|
||||
if not key:
|
||||
return None
|
||||
return get_project_states(project_id).get(key)
|
||||
|
||||
|
||||
def fetch_issue_sequence_id(issue_id: str, project_id: str) -> int | None:
|
||||
"""M-6: GET the Plane issue by UUID and return its sequence_id (the
|
||||
@@ -284,11 +407,12 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
|
||||
|
||||
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
"""Update Plane issue state based on orchestrator stage."""
|
||||
state_id = STAGE_TO_STATE.get(stage)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
# ORCH-10: resolve state UUID for this specific project (not global dict).
|
||||
state_id = stage_to_state(stage, project_id)
|
||||
if not state_id:
|
||||
return
|
||||
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
issue_id = find_issue_id(work_item_id, project_id)
|
||||
if not issue_id:
|
||||
logger.warning(f"Issue not found in Plane for {work_item_id}")
|
||||
@@ -327,20 +451,25 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st
|
||||
logger.error(f"Failed to add comment to {work_item_id}: {e}")
|
||||
|
||||
|
||||
|
||||
def set_issue_needs_input(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'Needs Input' state — waiting for stakeholder response."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["needs_input"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["needs_input"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_in_review(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'In Review' state — waiting for :approved: or :rejected:."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_review"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["in_review"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_blocked(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'Blocked' state — manual intervention needed."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["blocked"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["blocked"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_done(work_item_id: str, project_id: str = None):
|
||||
@@ -348,15 +477,19 @@ def set_issue_done(work_item_id: str, project_id: str = None):
|
||||
|
||||
Used by the deploy->done success path so a completed task always reaches the
|
||||
terminal Plane state (it used to stick on In Progress because the merge
|
||||
webhook bypassed the stage engine). Uses the existing PLANE_STATES['done']
|
||||
UUID — the mapping itself is NOT changed.
|
||||
webhook bypassed the stage engine). Resolves per-project UUID via
|
||||
get_project_states (ORCH-10).
|
||||
"""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["done"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["done"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_in_progress(work_item_id: str, project_id: str = None):
|
||||
"""Set issue to 'In Progress' state — agent working."""
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
state_id = get_project_states(project_id)["in_progress"]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None):
|
||||
@@ -371,7 +504,10 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None)
|
||||
state_key = STAGE_VISIBILITY_STATE.get(stage)
|
||||
if not state_key:
|
||||
return
|
||||
_set_issue_state_direct(work_item_id, PLANE_STATES[state_key], project_id)
|
||||
project_id = _resolve_project_id(work_item_id, project_id)
|
||||
# ORCH-10: resolve per-project UUID.
|
||||
state_id = get_project_states(project_id)[state_key]
|
||||
_set_issue_state_direct(work_item_id, state_id, project_id)
|
||||
|
||||
|
||||
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user