Files
orchestrator/tests/test_plane_webhook.py
Dev Agent 09b1c5e1b9 feat(webhook): start pipeline on In Progress status (not on create)
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.
2026-06-03 18:18:26 +03:00

189 lines
7.6 KiB
Python

"""ORCH-6: Plane webhook project-filter + repo-resolution tests.
Verifies the core of the 2026-06-02 incident fix:
* webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task
* webhook from the orchestrator project -> task created with repo=orchestrator
* webhook from the enduro project -> task created with repo=enduro-trails
launcher.launch is mocked so no real agents are spawned. Gitea branch/doc
creation is mocked (network). FastAPI TestClient drives the real endpoint.
This module configures its own registry via monkeypatch + reload_projects so it
is independent of ORCH_PROJECTS_JSON set by other test modules.
"""
import os
import tempfile
import pytest
# Test DB / disable signature checks (same convention as test_webhooks.py).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.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 # 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
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000"
client = TestClient(app)
@pytest.fixture(autouse=True)
def setup(monkeypatch):
"""Fresh DB + a known two-project registry for each test."""
# settings.db_path is resolved once at import; force it to our isolated DB so
# this suite is independent of whichever test module imported config first.
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()
# The webhook signature secret may be baked into the runtime env; this suite
# focuses on the project filter, so bypass signature verification.
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() # restore from env
if os.path.exists(_test_db):
os.unlink(_test_db)
# Feature 1: the pipeline now starts on a status change to In Progress (not on
# creation). _post_created drives that status-change event so these ORCH-6
# routing tests still exercise task creation through the new trigger.
_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
def _post_created(plane_project_id, plane_id="wi-1", 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"},
},
},
)
# ---------------------------------------------------------------------------
# Filter: unknown project is ignored, no side effects
# ---------------------------------------------------------------------------
@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)
def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher):
resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me")
assert resp.status_code == 200
assert resp.json()["status"] == "ignored"
assert resp.json().get("reason") == "unknown project"
# No task, no branch, no agent.
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone()
conn.close()
assert task is None
mock_branch.assert_not_called()
mock_launcher.launch.assert_not_called()
# ---------------------------------------------------------------------------
# orchestrator project -> repo=orchestrator, prefix ORCH
# ---------------------------------------------------------------------------
@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)
def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1")
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone()
conn.close()
assert task is not None
assert task["repo"] == "orchestrator"
assert task["work_item_id"].startswith("ORCH-")
assert task["stage"] == "analysis"
# Branch created against the orchestrator repo.
args = mock_branch.call_args.args
assert args[0] == "orchestrator"
# ---------------------------------------------------------------------------
# enduro project -> repo=enduro-trails, prefix ET
# ---------------------------------------------------------------------------
@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)
def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1")
assert resp.status_code == 200
assert resp.json()["status"] == "accepted"
conn = get_db()
task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone()
conn.close()
assert task is not None
assert task["repo"] == "enduro-trails"
assert task["work_item_id"].startswith("ET-")
args = mock_branch.call_args.args
assert args[0] == "enduro-trails"
# ---------------------------------------------------------------------------
# prefixes are independent per repo (ORCH-001 vs ET-001 in parallel)
# ---------------------------------------------------------------------------
@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)
def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher):
mock_launcher.launch.return_value = 1
_post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one")
_post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one")
_post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two")
conn = get_db()
rows = {r["plane_id"]: r["work_item_id"] for r in
conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()}
conn.close()
assert rows["o1"] == "ORCH-001"
assert rows["o2"] == "ORCH-002"
assert rows["e1"] == "ET-001"