issue.updated ships only the changed fields, so name was absent and the branch slug became feature/<id>-untitled. Add fetch_issue_fields (single issue-detail GET returning name+description, reusing the endpoint/token of fetch_issue_description) and pull the name above the slug build. Empty name still falls back to untitled.
214 lines
8.9 KiB
Python
214 lines
8.9 KiB
Python
"""Tests for the two pipeline-start bugs surfaced by the ET-006 live run.
|
|
|
|
BUG 1: issue.updated (status -> In Progress) ships a payload WITHOUT the
|
|
description, so start_pipeline must pull it from the Plane issue API
|
|
before QG-0 runs (otherwise QG-0 wrongly blocks the issue).
|
|
|
|
BUG 2a: M-6 derives work_item_id from the Plane sequence_id, which can collide.
|
|
ensure_unique_work_item_id() must hand out the next FREE id instead of
|
|
reusing one that is already in the tasks table.
|
|
|
|
BUG 2b: two tasks with an (artificially) identical work_item_id must not share a
|
|
branch/worktree.
|
|
|
|
launcher / Gitea / Plane network are mocked. Real FastAPI endpoint via
|
|
TestClient for the BUG 1 end-to-end path.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_pipeline_bugs.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "")
|
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
|
|
|
import pytest # noqa: E402
|
|
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, ensure_unique_work_item_id # noqa: E402
|
|
from src import projects as P # noqa: E402
|
|
from src.projects import reload_projects # noqa: E402
|
|
from src.git_worktree import get_worktree_path # noqa: E402
|
|
|
|
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
|
|
IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967"
|
|
BACKLOG = "113b24f6-cce8-4be9-9a22-a359b9cf0122"
|
|
|
|
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"}}]'
|
|
)
|
|
monkeypatch.setattr(P.settings, "projects_json", registry_json)
|
|
reload_projects()
|
|
yield
|
|
reload_projects()
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
def _insert_task(work_item_id, branch, plane_id="x"):
|
|
conn = get_db()
|
|
conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(plane_id, work_item_id, "enduro-trails", branch, "analysis", plane_id),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _count(plane_id):
|
|
conn = get_db()
|
|
n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0]
|
|
conn.close()
|
|
return n
|
|
|
|
|
|
def _task(plane_id):
|
|
conn = get_db()
|
|
row = conn.execute("SELECT * FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()
|
|
conn.close()
|
|
return row
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BUG 1
|
|
# --------------------------------------------------------------------------- #
|
|
def _to_in_progress_no_desc(plane_id="bug1"):
|
|
"""issue.updated payload WITHOUT description (only changed fields)."""
|
|
return client.post("/webhook/plane", json={
|
|
"event": "issue", "action": "updated",
|
|
"data": {
|
|
"id": plane_id, "name": "A valid backlog item title",
|
|
# NO description / description_stripped here, exactly like Plane sends
|
|
# on a status change.
|
|
"project": ENDURO_PLANE_ID,
|
|
"state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"},
|
|
},
|
|
"activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": BACKLOG},
|
|
})
|
|
|
|
|
|
@patch("src.webhooks.plane.enqueue_job", return_value=1)
|
|
@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=42)
|
|
@patch("src.plane_sync.fetch_issue_fields",
|
|
return_value=("A valid backlog item title",
|
|
"This is a sufficiently long description fetched from Plane API."))
|
|
def test_status_start_fetches_description(
|
|
mock_fields, mock_seq, mock_branch, mock_docs, mock_enqueue
|
|
):
|
|
"""BUG 1: empty description in payload -> start_pipeline pulls it from the
|
|
Plane API (single fetch_issue_fields GET) -> QG-0 passes -> task created +
|
|
analyst enqueued (NOT blocked)."""
|
|
resp = _to_in_progress_no_desc("bug1")
|
|
assert resp.status_code == 200
|
|
# name + description were pulled from the API in one call
|
|
mock_fields.assert_called_once()
|
|
# QG-0 passed -> task created and analyst launched (NOT set_issue_blocked)
|
|
assert _count("bug1") == 1
|
|
assert _task("bug1")["stage"] == "analysis"
|
|
mock_enqueue.assert_called_once()
|
|
assert mock_enqueue.call_args.args[0] == "analyst"
|
|
|
|
|
|
@patch("src.webhooks.plane.enqueue_job", return_value=1)
|
|
@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=42)
|
|
@patch("src.plane_sync.fetch_issue_fields", return_value=("", ""))
|
|
def test_status_start_empty_api_still_blocks(
|
|
mock_fields, mock_seq, mock_branch, mock_docs, mock_enqueue
|
|
):
|
|
"""BUG 1 negative path: if the API also returns empty, QG-0 legitimately
|
|
fails -> NO task is created (truly empty ticket)."""
|
|
resp = _to_in_progress_no_desc("bug1-empty")
|
|
assert resp.status_code == 200
|
|
mock_fields.assert_called_once()
|
|
assert _count("bug1-empty") == 0
|
|
mock_enqueue.assert_not_called()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BUG 2a
|
|
# --------------------------------------------------------------------------- #
|
|
def test_work_item_id_uniqueness():
|
|
"""BUG 2a: if ET-006 is already in tasks, the guard returns the next free
|
|
id (ET-007), not ET-006 again."""
|
|
_insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="old")
|
|
assert ensure_unique_work_item_id("ET-006", "enduro-trails") == "ET-007"
|
|
|
|
# ET-006 AND ET-007 taken -> next free is ET-008.
|
|
_insert_task("ET-007", "feature/ET-007-something", plane_id="old2")
|
|
assert ensure_unique_work_item_id("ET-006", "enduro-trails") == "ET-008"
|
|
|
|
# A free id is returned unchanged.
|
|
assert ensure_unique_work_item_id("ET-099", "enduro-trails") == "ET-099"
|
|
|
|
# Per-repo isolation: a different repo with the same id is not a collision.
|
|
assert ensure_unique_work_item_id("ET-006", "other-repo") == "ET-006"
|
|
|
|
|
|
@patch("src.webhooks.plane.enqueue_job", return_value=1)
|
|
@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=6)
|
|
@patch("src.plane_sync.fetch_issue_fields",
|
|
return_value=("Popup enduro trails feature",
|
|
"A sufficiently long description for QG-0 to pass cleanly."))
|
|
def test_collision_reassigns_in_start_pipeline(
|
|
mock_fields, mock_seq, mock_branch, mock_docs, mock_enqueue
|
|
):
|
|
"""BUG 2a end-to-end: ET-006 already exists -> a new In Progress issue whose
|
|
Plane sequence_id is also 6 must NOT reuse ET-006."""
|
|
_insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="task8")
|
|
resp = client.post("/webhook/plane", json={
|
|
"event": "issue", "action": "updated",
|
|
"data": {
|
|
"id": "task25", "name": "Popup enduro trails feature",
|
|
"description_stripped": "A sufficiently long description for QG-0.",
|
|
"project": ENDURO_PLANE_ID,
|
|
"state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"},
|
|
},
|
|
"activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": BACKLOG},
|
|
})
|
|
assert resp.status_code == 200
|
|
new_id = _task("task25")["work_item_id"]
|
|
assert new_id != "ET-006"
|
|
assert new_id == "ET-007"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# BUG 2b
|
|
# --------------------------------------------------------------------------- #
|
|
def test_worktree_per_task():
|
|
"""BUG 2b: two tasks must not resolve to the same worktree path. With the
|
|
uniqueness guard the branches differ, so the worktree paths differ too."""
|
|
_insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="task8")
|
|
# The second task gets a unique id via the guard...
|
|
new_id = ensure_unique_work_item_id("ET-006", "enduro-trails")
|
|
assert new_id == "ET-007"
|
|
branch_a = "feature/ET-006-gpx-upload"
|
|
branch_b = f"feature/{new_id}-popup-enduro-trails"
|
|
|
|
wt_a = get_worktree_path("enduro-trails", branch_a)
|
|
wt_b = get_worktree_path("enduro-trails", branch_b)
|
|
assert wt_a != wt_b, "two tasks must not share a worktree path"
|