From 1d978caea7fa26ed2f77f538e65da0dfe4465a2f Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Wed, 3 Jun 2026 10:02:15 +0300 Subject: [PATCH 1/2] feat(webhook): derive work_item_id from Plane sequence_id (M-6) --- src/plane_sync.py | 18 ++++ src/webhooks/plane.py | 16 +++- tests/test_m6_sequence.py | 181 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 tests/test_m6_sequence.py diff --git a/src/plane_sync.py b/src/plane_sync.py index 95e84df..ad88e9e 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -71,6 +71,24 @@ STAGE_TO_STATE = { } +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 + authoritative per-project number), or None if unavailable. + + Returns None on network error, non-2xx, or a missing field - never raises, + so the webhook handler can fall back to DB increment and stay autonomous. + """ + url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/{issue_id}/" + try: + resp = httpx.get(url, headers=PLANE_HEADERS, timeout=10) + resp.raise_for_status() + seq = resp.json().get("sequence_id") + return int(seq) if seq is not None else None + except Exception as e: + logger.warning(f"fetch_issue_sequence_id failed for {issue_id}: {e}") + return None + + def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: """Find Plane issue UUID by work_item_id (e.g. 'ET-002').""" project_id = _resolve_project_id(work_item_id, project_id) diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index f6d18e7..c457352 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -154,8 +154,20 @@ async def handle_work_item_created(data: dict, project_id: str = ""): logger.info(f"QG-0 failed for {plane_id}: {errors}") return - # Generate work item ID - work_item_id = get_next_work_item_id(repo, proj.work_item_prefix) + # Generate work item ID. + # M-6: source of truth for the number is the Plane sequence_id. Fetch it by + # issue UUID; if Plane is unavailable, fall back to the DB increment so a + # Plane outage never blocks task creation (autonomy > exact numbering). + from ..plane_sync import fetch_issue_sequence_id + seq = fetch_issue_sequence_id(plane_id, plane_project_id) + if seq is not None: + work_item_id = f"{proj.work_item_prefix}-{seq:03d}" + else: + work_item_id = get_next_work_item_id(repo, proj.work_item_prefix) + logger.warning( + f"Plane sequence_id unavailable for {plane_id}, " + f"fell back to DB increment: {work_item_id}" + ) # Create slug from name slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:30] diff --git a/tests/test_m6_sequence.py b/tests/test_m6_sequence.py new file mode 100644 index 0000000..83fc951 --- /dev/null +++ b/tests/test_m6_sequence.py @@ -0,0 +1,181 @@ +"""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" + +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) + + 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() + 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 +# --------------------------------------------------------------------------- + +def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"): + return client.post( + "/webhook/plane", + json={ + "event": "work_item.created", + "data": { + "id": plane_id, + "name": name, + "description_stripped": "This is a sufficiently long description.", + "project": plane_project_id, + }, + }, + ) + + +@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" From c431a3d05539e9f631f3f0a50159a1cabfff076f Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Wed, 3 Jun 2026 10:02:15 +0300 Subject: [PATCH 2/2] fix(plane_sync): drop hardcoded ET- prefix in find_issue_id (M-6) --- src/plane_sync.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/plane_sync.py b/src/plane_sync.py index ad88e9e..da2412d 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -113,25 +113,26 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None: resp.raise_for_status() data = resp.json() results = data.get("results", data if isinstance(data, list) else []) + # M-6: match by sequence_id directly (the authoritative per-project + # number), parsed from the work_item_id suffix - no hardcoded prefix. + try: + target_num = int(work_item_id.rsplit("-", 1)[1]) + except (IndexError, ValueError): + target_num = None for issue in results: - seq = issue.get("sequence_id") - identifier = f"ET-{seq:03d}" if seq else "" - if identifier == work_item_id or work_item_id in issue.get("name", ""): + if target_num is not None and issue.get("sequence_id") == target_num: return issue["id"] - # Fallback: get all issues and match by sequence_id number - if work_item_id.startswith("ET-"): - try: - target_num = int(work_item_id.split("-")[1]) - except (IndexError, ValueError): - target_num = None - if target_num: - resp2 = httpx.get(url, headers=PLANE_HEADERS, timeout=10) - resp2.raise_for_status() - data2 = resp2.json() - results2 = data2.get("results", data2 if isinstance(data2, list) else []) - for issue in results2: - if issue.get("sequence_id") == target_num: - return issue["id"] + if work_item_id in issue.get("name", ""): + return issue["id"] + # Fallback: get all issues and match by sequence_id number (any prefix) + if target_num is not None: + resp2 = httpx.get(url, headers=PLANE_HEADERS, timeout=10) + resp2.raise_for_status() + data2 = resp2.json() + results2 = data2.get("results", data2 if isinstance(data2, list) else []) + for issue in results2: + if issue.get("sequence_id") == target_num: + return issue["id"] except Exception as e: logger.error(f"Failed to find issue for {work_item_id}: {e}") return None