Feature 1. work_item.created no longer starts the pipeline (soft QG-0 log only); the issue stays in the backlog until moved to In Progress. The pipeline-start body is extracted into start_pipeline(); a new issue updated handler routes a state change to In Progress -> handle_status_start, which is idempotent: an existing task for the plane_id is NOT re-created or restarted (protects handle_comment, which also flips issues to In Progress). Real Plane payload: event=issue, action=updated, data.state.id. Existing m6/plane_webhook/dedup tests updated to drive the new trigger; new test_status_trigger.py covers created-no-op / start / idempotent.
188 lines
7.2 KiB
Python
188 lines
7.2 KiB
Python
"""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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Feature 1: pipeline starts on a status change to In Progress, not on creation.
|
|
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
|
|
|
|
|
def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"):
|
|
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"
|