"""Status-only verdict model (bug 3 fix). The comment-based control mechanism (:approved: / :rejected: / answer-to-questions) was removed. The pipeline is driven SOLELY by Plane status changes. These tests lock in the new behaviour: * test_inreview_comment_does_not_revert — bug 3 root: an In Review task, any comment arrives -> status NOT reverted, no agent launched. * test_any_comment_no_pipeline_action — :approved: / :rejected: / plain text comment -> no status change, no enqueue. * test_approved_status_advances_without_inprogress_reset — Approved status advances WITHOUT an intermediate set_issue_in_progress reset. * test_rejected_status_pulls_reason_from_comment — Rejected status pulls the reason from the issue's latest comment (mocked GET comments). """ import os import tempfile _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_status_only.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 # noqa: E402 from src import projects as P # noqa: E402 from src.projects import reload_projects # noqa: E402 ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" APPROVED = "a519a341-dada-4a91-8910-7604f82b79c5" REJECTED = "ba958f3c-5db5-461d-8f82-89425e413b97" IN_REVIEW = "38fb1f64-aa1e-48a3-92e0-0b109679046b" 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() # Seed a task at the 'review' stage for plane_id 'r-1'. conn = get_db() conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) " "VALUES (?, ?, ?, ?, ?, ?)", ("r-1", "ET-700", "enduro-trails", "feature/ET-700-x", "review", "r-1"), ) conn.commit() conn.close() yield reload_projects() if os.path.exists(_test_db): os.unlink(_test_db) class _FakeResp: def __init__(self, status_code, payload): self.status_code = status_code self._payload = payload def json(self): return self._payload def _comment(text, plane_id="r-1"): return client.post("/webhook/plane", json={ "event": "issue_comment", "action": "created", "data": {"work_item_id": plane_id, "comment_stripped": text, "project": ENDURO_PLANE_ID}, }) def _status(state_id, plane_id="r-1", old="prev"): return client.post("/webhook/plane", json={ "event": "issue", "action": "updated", "data": { "id": plane_id, "name": "Status task", "project": ENDURO_PLANE_ID, "state": {"id": state_id, "name": "X", "group": "started"}, }, "activity": {"field": "state", "new_value": state_id, "old_value": old}, }) def _stage(plane_id="r-1"): conn = get_db() row = conn.execute("SELECT stage FROM tasks WHERE plane_id=?", (plane_id,)).fetchone() conn.close() return row[0] if row else None # --------------------------------------------------------------------------- # # Bug 3 root: In Review must not revert on a comment. # --------------------------------------------------------------------------- # @patch("src.webhooks.plane.enqueue_job") @patch("src.plane_sync.set_issue_in_progress") @patch("src.plane_sync._set_issue_state_direct") @patch("src.plane_sync.update_issue_state") def test_inreview_comment_does_not_revert( mock_update_state, mock_set_direct, mock_sip, mock_enqueue ): """Bug 3: task in In Review, ANY comment arrives -> status NOT reverted to In Progress, NO agent launched. The analyst's own 'waiting for approval' comment used to echo back and self-hit -> reverted In Review -> In Progress. """ # analyst's own echo comment resp = _comment("Готово, жду approved") assert resp.status_code == 200 # no status changes whatsoever mock_sip.assert_not_called() mock_set_direct.assert_not_called() mock_update_state.assert_not_called() # no agent launched mock_enqueue.assert_not_called() # stage untouched assert _stage() == "review" # --------------------------------------------------------------------------- # # Any comment -> zero pipeline side-effects. # --------------------------------------------------------------------------- # @pytest.mark.parametrize("text", [":approved:", ":rejected: bad", "plain text", ""]) @patch("src.webhooks.plane.enqueue_job") @patch("src.webhooks.plane._try_advance_stage", new_callable=AsyncMock) @patch("src.webhooks.plane._rollback_stage", new_callable=AsyncMock) @patch("src.plane_sync.set_issue_in_progress") @patch("src.plane_sync._set_issue_state_direct") def test_any_comment_no_pipeline_action( mock_set_direct, mock_sip, mock_rollback, mock_advance, mock_enqueue, text ): resp = _comment(text) assert resp.status_code == 200 mock_advance.assert_not_called() mock_rollback.assert_not_called() mock_sip.assert_not_called() mock_set_direct.assert_not_called() mock_enqueue.assert_not_called() assert _stage() == "review" # --------------------------------------------------------------------------- # # Approved status advances WITHOUT in_progress reset. # --------------------------------------------------------------------------- # @patch("src.plane_sync.set_issue_in_progress") @patch("src.webhooks.plane._try_advance_stage", new_callable=AsyncMock) def test_approved_status_advances_without_inprogress_reset(mock_advance, mock_sip): resp = _status(APPROVED) assert resp.status_code == 200 mock_advance.assert_awaited_once() # work_item_id passed positionally assert "ET-700" in mock_advance.call_args.args # bug 3 (cause B): NO intermediate set_issue_in_progress before advance. mock_sip.assert_not_called() # --------------------------------------------------------------------------- # # Rejected status pulls reason from latest comment. # --------------------------------------------------------------------------- # @patch("src.webhooks.plane.httpx.get") @patch("src.webhooks.plane._rollback_stage", new_callable=AsyncMock) def test_rejected_status_pulls_reason_from_comment(mock_rollback, mock_get): mock_get.return_value = _FakeResp(200, {"results": [ {"comment_stripped": "old comment", "created_at": "2026-06-03T09:00:00Z"}, {"comment_html": "

Needs more test coverage

", "created_at": "2026-06-03T11:30:00Z"}, ]}) resp = _status(REJECTED) assert resp.status_code == 200 mock_rollback.assert_awaited_once() reason = mock_rollback.call_args.args[-1] # latest by created_at, HTML stripped assert "Needs more test coverage" in reason assert "

" not in reason @patch("src.webhooks.plane.httpx.get") @patch("src.webhooks.plane._rollback_stage", new_callable=AsyncMock) def test_rejected_status_no_comment_uses_fallback(mock_rollback, mock_get): mock_get.return_value = _FakeResp(200, {"results": []}) resp = _status(REJECTED) assert resp.status_code == 200 mock_rollback.assert_awaited_once() reason = mock_rollback.call_args.args[-1] assert "no reason comment" in reason