"""M-6: work_item_id derived from Plane sequence_id (source of truth = Plane). Covers: * fetch_issue_sequence_id returns int on a valid Plane response (mocked httpx); * returns None on network error / missing field WITHOUT raising; * handle_work_item_created uses prefix-NNN when seq is available, and falls back to get_next_work_item_id when seq is None (Plane down => autonomy); * find_issue_id no longer hardcodes 'ET-' and matches an arbitrary prefix (e.g. ORCH-005) by sequence_id. """ import os import tempfile import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_m6.db") os.environ["ORCH_DB_PATH"] = _test_db os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from unittest.mock import patch, AsyncMock, MagicMock # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 from src.main import app # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import projects as P # noqa: E402 from src.projects import reload_projects # noqa: E402 import src.plane_sync as plane_sync # noqa: E402 ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" # ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via # get_project_states(project_id). Mock it deterministically (no network) and # send each request with the UUID that matches its own project. _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", # ORCH-066: To Analyse is the start trigger; with the status absent it # aliases to in_progress (the real get_project_states fallback). "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", }, } def _fake_get_project_states(project_id): return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID]) client = TestClient(app) @pytest.fixture(autouse=True) def setup(monkeypatch): monkeypatch.setattr(P.settings, "db_path", _test_db) import src.db as _db monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) # ORCH-39: deterministic per-project Plane states, clean cache per test. plane_sync.reload_project_states() monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states) registry_json = ( f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f' "work_item_prefix": "ET", "name": "enduro-trails"}},' f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",' f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]' ) monkeypatch.setattr(P.settings, "projects_json", registry_json) reload_projects() yield reload_projects() plane_sync.reload_project_states() if os.path.exists(_test_db): os.unlink(_test_db) def _mock_resp(json_body, status=200): m = MagicMock() m.json.return_value = json_body m.raise_for_status.return_value = None if status >= 400: def _raise(): raise RuntimeError(f"HTTP {status}") m.raise_for_status.side_effect = _raise return m # --------------------------------------------------------------------------- # fetch_issue_sequence_id # --------------------------------------------------------------------------- def test_fetch_sequence_id_returns_int(): with patch.object(plane_sync.httpx, "get", return_value=_mock_resp({"sequence_id": 42})): seq = plane_sync.fetch_issue_sequence_id("issue-uuid", "proj-uuid") assert seq == 42 assert isinstance(seq, int) def test_fetch_sequence_id_network_error_returns_none(): with patch.object(plane_sync.httpx, "get", side_effect=RuntimeError("connection refused")): seq = plane_sync.fetch_issue_sequence_id("issue-uuid", "proj-uuid") assert seq is None # must not raise def test_fetch_sequence_id_missing_field_returns_none(): with patch.object(plane_sync.httpx, "get", return_value=_mock_resp({"error": "not found"})): seq = plane_sync.fetch_issue_sequence_id("missing-uuid", "proj-uuid") assert seq is None # --------------------------------------------------------------------------- # handle_work_item_created: seq available -> prefix-NNN # --------------------------------------------------------------------------- # Feature 1: pipeline starts on a status change to In Progress, not on creation. # ORCH-39: in_progress UUID is project-specific; derive it from the project. def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"): in_progress = _fake_get_project_states(plane_project_id)["in_progress"] return client.post( "/webhook/plane", json={ "event": "issue", "action": "updated", "data": { "id": plane_id, "name": name, "description_stripped": "This is a sufficiently long description.", "project": plane_project_id, "state": {"id": in_progress, "name": "In Progress", "group": "started"}, }, }, ) @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) @patch("src.plane_sync.fetch_issue_sequence_id", return_value=7) def test_created_uses_plane_sequence_id(mock_fetch, mock_branch, mock_docs, mock_launcher): mock_launcher.launch.return_value = 1 resp = _post("seq-issue") assert resp.status_code == 200 conn = get_db() task = conn.execute("SELECT work_item_id FROM tasks WHERE plane_id='seq-issue'").fetchone() conn.close() assert task is not None assert task["work_item_id"] == "ORCH-007" mock_fetch.assert_called_once() @patch("src.webhooks.plane.launcher") @patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) @patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) @patch("src.plane_sync.fetch_issue_sequence_id", return_value=None) @patch("src.webhooks.plane.get_next_work_item_id", return_value="ORCH-099") def test_created_falls_back_to_db_when_plane_down( mock_next, mock_fetch, mock_branch, mock_docs, mock_launcher ): """Plane unavailable (seq=None) => fall back to DB increment; task still created.""" mock_launcher.launch.return_value = 1 resp = _post("fallback-issue") assert resp.status_code == 200 conn = get_db() task = conn.execute("SELECT work_item_id FROM tasks WHERE plane_id='fallback-issue'").fetchone() conn.close() assert task is not None # autonomy: Plane down does not block creation assert task["work_item_id"] == "ORCH-099" mock_next.assert_called_once() # --------------------------------------------------------------------------- # find_issue_id: no hardcoded ET- prefix, matches arbitrary prefix by seq # --------------------------------------------------------------------------- def test_find_issue_id_matches_arbitrary_prefix_by_sequence(): """ORCH-005 must resolve via the issue whose sequence_id == 5 (no ET- assumption).""" issues = {"results": [ {"id": "uuid-a", "sequence_id": 3, "name": "something"}, {"id": "uuid-b", "sequence_id": 5, "name": "ORCH-005: target"}, {"id": "uuid-c", "sequence_id": 9, "name": "other"}, ]} # No DB row for this work_item_id => goes to the Plane API search branch. with patch.object(plane_sync.httpx, "get", return_value=_mock_resp(issues)): found = plane_sync.find_issue_id("ORCH-005", project_id="proj-uuid") assert found == "uuid-b" def test_find_issue_id_matches_et_prefix_too(): """Backward compat: ET-002 still resolves by sequence_id == 2.""" issues = {"results": [ {"id": "uuid-x", "sequence_id": 2, "name": "ET item"}, {"id": "uuid-y", "sequence_id": 7, "name": "other"}, ]} with patch.object(plane_sync.httpx, "get", return_value=_mock_resp(issues)): found = plane_sync.find_issue_id("ET-002", project_id="proj-uuid") assert found == "uuid-x"