From a4668c0303a0485f6eb006665fec85e0c008f8dd Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Wed, 3 Jun 2026 18:18:17 +0300 Subject: [PATCH] feat(plane): stage visibility on board + verdict status UUIDs Feature 3 + Feature 2 infra. Extend the global PLANE_STATES with the 6 new enduro status UUIDs (architecture/development/review/testing + approved/rejected), remap STAGE_TO_STATE so the 4 mid-pipeline stages move the issue across its own board column instead of all sitting in In Progress, and add the set_issue_stage_state() helper. Needs Input / In Review / Blocked keep their own explicit setters and stay higher priority. TODO(ORCH-10): statuses are per-project; resolve per project when more projects are onboarded. --- src/plane_sync.py | 55 +++++++++++++++++--- tests/test_stage_visibility.py | 94 ++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 tests/test_stage_visibility.py diff --git a/src/plane_sync.py b/src/plane_sync.py index 1f9fd72..e96900a 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -84,7 +84,12 @@ 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 +# 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", @@ -94,16 +99,39 @@ PLANE_STATES = { "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", + # Feature 2 (verdict statuses) — Approved / Rejected. + "approved": "a519a341-dada-4a91-8910-7604f82b79c5", + "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", } -# Map orchestrator stages to Plane states +# 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 +# in_progress until done. Needs Input / In Review / Blocked remain higher +# priority and are set explicitly elsewhere — do NOT override them from here. +STAGE_VISIBILITY_STATE = { + "architecture": "architecture", + "development": "development", + "review": "review", + "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 = { "created": PLANE_STATES["todo"], "analysis": PLANE_STATES["in_progress"], - "architecture": PLANE_STATES["in_progress"], - "development": PLANE_STATES["in_progress"], - "review": PLANE_STATES["in_progress"], - "testing": 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"], } @@ -242,6 +270,21 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None): _set_issue_state_direct(work_item_id, PLANE_STATES["in_progress"], project_id) +def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None): + """Feature 3: move the issue to the board status for a pipeline stage. + + Only the visible-stage statuses (architecture/development/review/testing) + are driven here — stages without a dedicated status (analysis/deploy) are a + no-op so the existing in_progress/in_review/needs_input logic stays in + charge. By design this does NOT touch Needs Input / In Review / Blocked, + which are higher priority and set explicitly by their own helpers. + """ + 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) + + def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None): """Set issue state directly by state_id.""" project_id = _resolve_project_id(work_item_id, project_id) diff --git a/tests/test_stage_visibility.py b/tests/test_stage_visibility.py new file mode 100644 index 0000000..a41f5c7 --- /dev/null +++ b/tests/test_stage_visibility.py @@ -0,0 +1,94 @@ +"""Feature 3: stage visibility on the Plane board. + + * PLANE_STATES carries the 6 new per-stage / verdict UUIDs. + * STAGE_TO_STATE maps architecture/development/review/testing to their + dedicated board statuses (not all -> In Progress anymore). + * set_issue_stage_state(work_item_id, stage) PATCHes the correct state UUID + for a visible stage, and is a no-op for stages without one (analysis/deploy). + * Needs Input / In Review / Blocked remain higher priority: their explicit + setters use their own state, never overwritten by the stage map. + +httpx is mocked; no network. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + + +EXPECTED_UUIDS = { + "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", +} + + +def test_plane_states_has_new_uuids(): + for key, uuid in EXPECTED_UUIDS.items(): + assert PS.PLANE_STATES[key] == uuid + + +def test_stage_to_state_maps_visible_stages(): + assert PS.STAGE_TO_STATE["architecture"] == EXPECTED_UUIDS["architecture"] + assert PS.STAGE_TO_STATE["development"] == EXPECTED_UUIDS["development"] + assert PS.STAGE_TO_STATE["review"] == EXPECTED_UUIDS["review"] + assert PS.STAGE_TO_STATE["testing"] == EXPECTED_UUIDS["testing"] + # analysis / deploy stay on In Progress; done stays Done. + assert PS.STAGE_TO_STATE["analysis"] == PS.PLANE_STATES["in_progress"] + assert PS.STAGE_TO_STATE["deploy"] == PS.PLANE_STATES["in_progress"] + assert PS.STAGE_TO_STATE["done"] == PS.PLANE_STATES["done"] + + +def _patch_resolution(monkey_targets): + """Helper: patch find_issue_id + _resolve_project_id to skip the DB/network.""" + return monkey_targets + + +@patch("src.plane_sync.httpx.patch") +@patch("src.plane_sync.find_issue_id", return_value="issue-uuid") +@patch("src.plane_sync._resolve_project_id", return_value="proj-1") +def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_patch): + resp = MagicMock(); resp.raise_for_status.return_value = None + mock_patch.return_value = resp + + PS.set_issue_stage_state("ET-1", "development") + # the PATCH carried the development state UUID + _, kwargs = mock_patch.call_args + assert kwargs["json"]["state"] == EXPECTED_UUIDS["development"] + + +@patch("src.plane_sync.httpx.patch") +@patch("src.plane_sync.find_issue_id", return_value="issue-uuid") +@patch("src.plane_sync._resolve_project_id", return_value="proj-1") +def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch): + # analysis has no dedicated board status -> no PATCH at all. + PS.set_issue_stage_state("ET-1", "analysis") + mock_patch.assert_not_called() + PS.set_issue_stage_state("ET-1", "deploy") + mock_patch.assert_not_called() + + +@patch("src.plane_sync.httpx.patch") +@patch("src.plane_sync.find_issue_id", return_value="issue-uuid") +@patch("src.plane_sync._resolve_project_id", return_value="proj-1") +def test_priority_states_use_their_own_uuid(mock_proj, mock_find, mock_patch): + """Needs Input / In Review / Blocked are set explicitly and take priority.""" + resp = MagicMock(); resp.raise_for_status.return_value = None + mock_patch.return_value = resp + + PS.set_issue_needs_input("ET-1") + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["needs_input"] + + PS.set_issue_in_review("ET-1") + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["in_review"] + + PS.set_issue_blocked("ET-1") + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["blocked"]