diff --git a/src/plane_sync.py b/src/plane_sync.py index b09f366..6d3ee14 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -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//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): diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index cdcc2e7..ea7d238 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -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"

{error_text}

"}, 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}") diff --git a/tests/test_orch10_states.py b/tests/test_orch10_states.py new file mode 100644 index 0000000..c753fd5 --- /dev/null +++ b/tests/test_orch10_states.py @@ -0,0 +1,462 @@ +"""ORCH-10: per-project Plane state resolution tests. + +Verifies: + 1. get_project_states(ET_PROJECT_ID) -> enduro-trails UUIDs (backward compat). + 2. get_project_states(ORCH_PROJECT_ID) -> orchestrator UUIDs. + 3. get_project_states falls back to _DEFAULT_STATES when the Plane API fails. + 4. _STATES_CACHE is populated after a successful call and reload_project_states + evicts it (per-project and full flush). + 5. stage_to_state() resolves per-project UUIDs for both projects. + 6. Webhook handle_issue_updated recognises In Progress for BOTH projects + (ORCH-10 critical path: e331bfb3 for ORCH, b873d9eb for ET -> pipeline start). + 7. Webhook handle_issue_updated recognises Approved/Rejected per project. +""" + +import os +import sys +import tempfile +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +# --------------------------------------------------------------------------- +# Minimal env so src/config.py can import without a real .env file. +# --------------------------------------------------------------------------- +os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch10_states.db") +os.environ["ORCH_DB_PATH"] = _test_db + +# --------------------------------------------------------------------------- +# Known UUIDs from the ТЗ (source of truth). +# --------------------------------------------------------------------------- +ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + +ET_STATES = { + "backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122", + "todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10", + "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + "architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d", + "development": "9920609b-f140-4e46-ab95-89acda8412c8", + "review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", + "testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02", + "approved": "a519a341-dada-4a91-8910-7604f82b79c5", + "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + "done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", + "cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17", + "needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac", + "in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b", + "blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920", +} + +ORCH_STATES = { + "backlog": "2d5d42ff-e94d-4209-a664-8020c28c2a95", + "todo": "b5d3f512-4870-460f-bf6b-4ea560f00a6f", + "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "architecture": "795cc32f-5f5a-4244-be7b-9acffc92c7c0", + "development": "f5ed4705-5029-470d-89a9-54c3f0d211ee", + "review": "2026f3d9-0f43-4054-ab5f-3f9bae3308b8", + "testing": "81c5cd78-2993-4f2c-9e8c-2f52db3e5623", + "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", + "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + "done": "3738cd3c-7610-4907-ba5e-26b9a248d9c0", + "cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85", + "needs_input": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", + "in_review": "c52e99b9-31ae-4b31-be3f-9773eea7a747", + "blocked": "505f01a6-a12f-4121-aaa7-9c5dd009acc4", +} + + +def _make_states_response(states_dict: dict) -> dict: + """Build a fake Plane GET /states/ response.""" + name_map = {v: k for k, v in { + "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", + }.items()} + logical_to_plane = { + "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", + } + results = [ + {"id": uid, "name": logical_to_plane[key]} + for key, uid in states_dict.items() + if key in logical_to_plane + ] + return {"results": results} + + +# --------------------------------------------------------------------------- +# Helpers to build fake httpx responses. +# --------------------------------------------------------------------------- + +def _fake_response(data: dict, status: int = 200): + m = MagicMock() + m.status_code = status + m.json.return_value = data + if status >= 400: + from httpx import HTTPStatusError, Request, Response + m.raise_for_status.side_effect = HTTPStatusError( + "error", request=MagicMock(), response=MagicMock() + ) + else: + m.raise_for_status.return_value = None + return m + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def reset_states_cache(): + """Ensure the states cache is empty before each test.""" + import src.plane_sync as ps + ps.reload_project_states() + yield + ps.reload_project_states() + + +# --------------------------------------------------------------------------- +# 1 & 2. get_project_states returns correct UUIDs per project +# --------------------------------------------------------------------------- + +def test_get_project_states_enduro(): + """ET project -> enduro-trails UUIDs.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + states = ps.get_project_states(ET_PROJECT_ID) + + for key, expected_uuid in ET_STATES.items(): + assert states[key] == expected_uuid, ( + f"ET state '{key}': expected {expected_uuid}, got {states.get(key)}" + ) + + +def test_get_project_states_orchestrator(): + """ORCH project -> orchestrator UUIDs.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + states = ps.get_project_states(ORCH_PROJECT_ID) + + for key, expected_uuid in ORCH_STATES.items(): + assert states[key] == expected_uuid, ( + f"ORCH state '{key}': expected {expected_uuid}, got {states.get(key)}" + ) + + +def test_get_project_states_et_in_progress_uuid(): + """ET in_progress == b873d9eb (exact UUID from ТЗ).""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + states = ps.get_project_states(ET_PROJECT_ID) + assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967" + + +def test_get_project_states_orch_in_progress_uuid(): + """ORCH in_progress == e331bfb3 (exact UUID from ТЗ) — the ORCH-10 blocker.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + states = ps.get_project_states(ORCH_PROJECT_ID) + assert states["in_progress"] == "e331bfb3-e17e-4699-ba48-4abb89c21b7b" + + +# --------------------------------------------------------------------------- +# 3. Fallback to _DEFAULT_STATES when API fails +# --------------------------------------------------------------------------- + +def test_get_project_states_api_error_fallback(): + """Network failure -> returns _DEFAULT_STATES (ET values).""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get", side_effect=Exception("network error")): + states = ps.get_project_states(ORCH_PROJECT_ID) + # Should return _DEFAULT_STATES (ET values) as fallback. + assert states is ps._DEFAULT_STATES + + +def test_get_project_states_non_200_fallback(): + """Non-2xx response -> returns _DEFAULT_STATES.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response({}, status=500) + states = ps.get_project_states(ORCH_PROJECT_ID) + assert states is ps._DEFAULT_STATES + + +def test_get_project_states_empty_response_fallback(): + """Empty results list -> returns _DEFAULT_STATES.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response({"results": []}) + states = ps.get_project_states(ORCH_PROJECT_ID) + assert states is ps._DEFAULT_STATES + + +def test_get_project_states_none_project_id_fallback(): + """None project_id -> _DEFAULT_STATES immediately (no API call).""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + states = ps.get_project_states(None) + mock_get.assert_not_called() + assert states is ps._DEFAULT_STATES + + +# --------------------------------------------------------------------------- +# 4. Caching & reload_project_states +# --------------------------------------------------------------------------- + +def test_get_project_states_caches_result(): + """Second call returns cached result without hitting the API again.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + _ = ps.get_project_states(ET_PROJECT_ID) + _ = ps.get_project_states(ET_PROJECT_ID) + assert mock_get.call_count == 1 + + +def test_reload_project_states_per_project(): + """reload_project_states(project_id) evicts only that project.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + ps.get_project_states(ET_PROJECT_ID) + assert ET_PROJECT_ID in ps._STATES_CACHE + + ps.reload_project_states(ET_PROJECT_ID) + assert ET_PROJECT_ID not in ps._STATES_CACHE + + +def test_reload_project_states_full_flush(): + """reload_project_states() with no args clears entire cache.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + ps.get_project_states(ET_PROJECT_ID) + ps.reload_project_states() + assert ps._STATES_CACHE == {} + + +# --------------------------------------------------------------------------- +# 5. stage_to_state() resolves per-project +# --------------------------------------------------------------------------- + +def test_stage_to_state_et_analysis(): + """ET analysis -> in_progress UUID b873d9eb.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + uid = ps.stage_to_state("analysis", ET_PROJECT_ID) + assert uid == "b873d9eb-993c-48cd-97ac-99a9b1623967" + + +def test_stage_to_state_orch_analysis(): + """ORCH analysis -> in_progress UUID e331bfb3.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + uid = ps.stage_to_state("analysis", ORCH_PROJECT_ID) + assert uid == "e331bfb3-e17e-4699-ba48-4abb89c21b7b" + + +def test_stage_to_state_unknown_stage(): + """Unknown stage -> None.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) + uid = ps.stage_to_state("nonexistent_stage", ET_PROJECT_ID) + assert uid is None + + +def test_stage_to_state_orch_done(): + """ORCH done -> 3738cd3c.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + uid = ps.stage_to_state("done", ORCH_PROJECT_ID) + assert uid == "3738cd3c-7610-4907-ba5e-26b9a248d9c0" + + +# --------------------------------------------------------------------------- +# 6 & 7. Webhook handle_issue_updated — ORCH-10 critical path +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_webhook_in_progress_et_starts_pipeline(): + """ET In Progress (b873d9eb) -> handle_status_start called.""" + from src.webhooks.plane import handle_issue_updated + import src.plane_sync as ps + + et_states_resp = _make_states_response(ET_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(et_states_resp) + data = { + "id": "et-issue-uuid", + "state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"}, + } + await handle_issue_updated(data, ET_PROJECT_ID) + + mock_start.assert_called_once() + mock_verdict.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_in_progress_orch_starts_pipeline(): + """ORCH In Progress (e331bfb3) -> handle_status_start called. + + This is the ORCH-10 blocker: previously the webhook compared against the + hardcoded ET UUID (b873d9eb) and the ORCH UUID (e331bfb3) was silently + ignored — the pipeline never started for ORCH tasks. + """ + from src.webhooks.plane import handle_issue_updated + import src.plane_sync as ps + + orch_states_resp = _make_states_response(ORCH_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(orch_states_resp) + data = { + "id": "orch-issue-uuid", + "state": {"id": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "name": "In Progress"}, + } + await handle_issue_updated(data, ORCH_PROJECT_ID) + + mock_start.assert_called_once() + mock_verdict.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_approved_orch(): + """ORCH Approved (63f2c8fe) -> handle_verdict(approved=True).""" + from src.webhooks.plane import handle_issue_updated + orch_states_resp = _make_states_response(ORCH_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(orch_states_resp) + data = { + "id": "orch-issue-uuid", + "state": {"id": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "name": "Approved"}, + } + await handle_issue_updated(data, ORCH_PROJECT_ID) + + mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=True) + mock_start.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_rejected_orch(): + """ORCH Rejected (4c769e90) -> handle_verdict(approved=False).""" + from src.webhooks.plane import handle_issue_updated + orch_states_resp = _make_states_response(ORCH_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(orch_states_resp) + data = { + "id": "orch-issue-uuid", + "state": {"id": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", "name": "Rejected"}, + } + await handle_issue_updated(data, ORCH_PROJECT_ID) + + mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=False) + mock_start.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_other_state_no_action(): + """A non-trigger state (e.g. 'Needs Input') -> no pipeline action.""" + from src.webhooks.plane import handle_issue_updated + orch_states_resp = _make_states_response(ORCH_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(orch_states_resp) + data = { + "id": "orch-issue-uuid", + "state": {"id": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", "name": "Needs Input"}, + } + await handle_issue_updated(data, ORCH_PROJECT_ID) + + mock_start.assert_not_called() + mock_verdict.assert_not_called() + + +@pytest.mark.asyncio +async def test_webhook_et_in_progress_not_confused_with_orch(): + """ET In Progress UUID does NOT trigger pipeline for ORCH project. + + This guards against the reverse confusion: if somehow an ET UUID was sent + for an ORCH project event, it should NOT start the pipeline (wrong UUID). + """ + from src.webhooks.plane import handle_issue_updated + orch_states_resp = _make_states_response(ORCH_STATES) + with patch("src.plane_sync.httpx.get") as mock_httpx, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_httpx.return_value = _fake_response(orch_states_resp) + # Send ET's in_progress UUID for an ORCH project event. + data = { + "id": "orch-issue-uuid", + "state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"}, + } + await handle_issue_updated(data, ORCH_PROJECT_ID) + + # Since ORCH in_progress is e331bfb3, ET's b873d9eb should NOT trigger start. + mock_start.assert_not_called() + mock_verdict.assert_not_called() + + +# --------------------------------------------------------------------------- +# 8. _DEFAULT_STATES / PLANE_STATES alias preserved +# --------------------------------------------------------------------------- + +def test_plane_states_alias_is_default_states(): + """PLANE_STATES is still exported and equals _DEFAULT_STATES (backward compat).""" + import src.plane_sync as ps + assert ps.PLANE_STATES is ps._DEFAULT_STATES + + +def test_default_states_et_values(): + """_DEFAULT_STATES contains the original enduro-trails UUIDs.""" + import src.plane_sync as ps + for key, expected in ET_STATES.items(): + assert ps._DEFAULT_STATES[key] == expected, ( + f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}" + )