From 43908518b70574fc5c15c1b370096460471fe57f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 17 Jun 2026 14:22:27 +0300 Subject: [PATCH] fix(webhooks): source-backed 00-business-request.md instead of hardcoded TBD (ORCH-119) The Description section of 00-business-request.md always read the literal `TBD`, losing the source-backed Plane-issue request context. Render the ACTUAL issue `description` on both creation paths: - Direct path A (serial_gate N/A): start_pipeline passes `description` to _create_initial_docs. - Deferred path B (ORCH-088, dominates on self-hosting): persist `description` durable in the additive `tasks.description` column inside the same atomic INSERT in create_task_atomic (race-safe vs ORCH-053 anti-dup claim), read it in launcher._spawn -> _materialize_deferred_branch at claim (no network in the hot claim path, NFR-4). Pure render helper _render_business_request with a fail-safe fallback marker for empty/None/unreadable descriptions (never breaks task creation); Gitea 422 stays a no-op (idempotent). STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys and the base CREATE TABLE tasks are byte-for-byte unchanged; the ORCH-088 anti-stale-base invariant is preserved (only the data source is enriched). Tests: tests/test_orch119_business_request.py (TC-01 mandatory red->green regression; TC-02..TC-07). Updated the ORCH-088 serial-gate spy for the additive _create_initial_docs arg. Refs: ORCH-119 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/agents/launcher.py | 22 ++- src/db.py | 27 ++- src/webhooks/plane.py | 60 +++++- tests/test_orch119_business_request.py | 263 +++++++++++++++++++++++++ tests/test_serial_gate_branch.py | 4 +- 6 files changed, 364 insertions(+), 13 deletions(-) create mode 100644 tests/test_orch119_business_request.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c74080..1719dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Source-backed `00-business-request.md` вместо хардкода `TBD`** (ORCH-119, `fix`, Bug-трек): раздел «Description» файла `00-business-request.md` теперь несёт **фактический текст запроса** из Plane-issue (`description`/`description_stripped`) вместо литерала `TBD` — терялся source-backed контекст запроса. Фикс работает на **обоих** путях создания: прямой (путь A, `serial_gate` не применим — `start_pipeline` передаёт `description` в `_create_initial_docs`) и **отложенный срез ветки** (путь B, ORCH-088, доминирует на self-hosting `orchestrator`). Для пути B `description` **персистится durable** при создании задачи (аддитивная колонка `tasks.description` через `_ensure_column`, зеркало `tasks.title`, записывается **внутри того же атомарного INSERT** `create_task_atomic` — race-safe относительно анти-dup-claim ORCH-053) и читается из строки `tasks` в `launcher._spawn` → `_materialize_deferred_branch` на момент claim (без сетевого вызова в горячем пути, NFR-4). **Fail-safe (FR-4):** пустое/whitespace/`None`/нечитаемое описание → явный безопасный маркер `_(описание отсутствует в источнике)_` через чистый рендер-хелпер `_render_business_request` (never-raise; создание задачи не падает). **Идемпотентность:** Gitea 422 (файл существует) → no-op, ранее записанное тело не перезаписывается. **Инвариант (AC-5):** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict-ключи — байт-в-байт; единственное изменение схемы — аддитивная `tasks.description` (базовый `CREATE TABLE tasks` не тронут); анти-stale-base инвариант ORCH-088 цел (момент/условие среза не меняются — только источник данных дополняется). Обратимость — revert PR (колонка остаётся инертной). Покрытие — `tests/test_orch119_business_request.py` (TC-01 обязательный регресс red→green; TC-02…TC-07). ADR: `docs/work-items/ORCH-119/06-adr/ADR-001-source-backed-business-request-doc.md`. - **Детерминированный test-раннер вместо LLM-тестера на `testing`** (ORCH-116, `feat`): второй реализованный срез determinization-roadmap (ORCH-118 A5, `needs-hybrid-fallback`) — на стадии `testing` для self-hosting `orchestrator` **LLM-агент `tester` заменён детерминированным кодом** (`src/test_runner.py`). PASS/FAIL-ядро агента было деривируемым (регресс `pytest` + read-only smoke → `result:`); каждый прогон жёг токены/время opus-агента (~60–150k / 5–20 мин) и встраивал недетерминизм LLM в точку ветвления `testing → deploy-staging` / `testing → development`. **Инвариант (NFR-1):** это замена *продюсера* артефакта, **не** гейта — контракт `13-test-report.md`, гейт `check_tests_passed`/`_parse_tests_verdict`, `STAGE_TRANSITIONS`, machine-verdict `result:` (+ legacy `verdict:`/`status:`), схема БД — **байт-в-байт не тронуты**. Аддитивно, под kill-switch, never-raise, fail-closed, скоуп self-hosting, гибрид (LLM строго off-control-path). Эталон — `src/staging_runner.py` (ORCH-115). ADR: `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md`, сквозной `docs/architecture/adr/adr-0050-deterministic-test-runner.md`. - **Перехват в `launch_job` до `_spawn` (D1):** `if job.agent=="tester" and test_runner.should_intercept(job)` → `_run_test_runner_job` (зеркало `_run_staging_runner_job`, прецедент `deploy-finalizer`/`post-deploy-monitor`/`staging-runner` `launcher.py:397/402/405`): синхронно ведёт `jobs`-строку через `mark_job`, возвращает `None` (нет `agent_runs`, нет токенов). Дискриминатор — роль `tester` **И** стадия задачи `testing` (defense-in-depth: `tester` — единственный агент входа в `testing`, коллизии стадий нет, в отличие от общей роли `deployer`) **И** `applies(repo)`; `should_intercept` never-raise → `False` → штатный `_spawn` (fail-safe к LLM-пути). - **Leaf `src/test_runner.py` (новый, чистый never-raise):** по образцу `staging_runner`/`self_deploy`/`proc_group` (на импорте только `config`/`proc_group`; `db`/`git_worktree`/`self_deploy`/`qg.checks`/`stage_engine`/`notifications` — лениво). `applies(repo)` = kill-switch `test_runner_enabled` + скоуп `test_runner_repos` (пусто → self-hosting only) **И** резолв тест-контракта `_has_test_contract` (BR-9: репо без контракта → `False` → LLM-tester — enduro-trails 1:1 как до ORCH-116, даже если руками добавлен в CSV). Исполняет регресс `python -m pytest ` **в worktree ветки** (`git_worktree.get_worktree_path`, анти checkout-гонка ORCH-112) через `proc_group.run_in_process_group` (tree-kill, таймаут `test_runner_timeout_s=900`, малформ/непозитив → дефолт + WARNING) + опц. **read-only smoke** (`/health`/`/status`/`/queue` + блок `serial_gate`, stdlib `urllib`; транзиентная недостижимость — ограниченный ретрай, не-200/нет блока — немедленный FAIL; `test_runner_smoke_enabled`). Маппит exit-код **единым** контрактом `self_deploy.map_exit_code_to_status` в токенах `result:` (`0→PASS`/иначе/None→`FAIL`, fail-closed; smoke-провал AND-ится в `FAIL`); пишет `13-test-report.md` (тот же machine-key `result:` UPPERCASE + 52c-схема, `author_agent: test-runner`/`model_used: n/a`) + best-effort push в **фичеветку**; вызывает **существующий** `advance_stage(current_stage="testing", finished_agent="tester")` — без новых рёбер/исходов (transition-lease ORCH-114 берётся внутри `advance_stage` — граница O1). diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 643a4e5..65258de 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -512,7 +512,8 @@ class AgentLauncher: return None def _materialize_deferred_branch( - self, repo: str, branch: str, work_item_id: str | None, title: str | None + self, repo: str, branch: str, work_item_id: str | None, title: str | None, + description: str | None = None, ) -> None: """ORCH-088 (ADR-001 D1): create the deferred Gitea branch + initial docs. @@ -524,6 +525,12 @@ class AgentLauncher: Both are idempotent (409/422 -> no-op) so a re-claim after a restart is safe. A transient Gitea error PROPAGATES so the caller (_spawn) fails the launch and the queue worker requeues the job for a later tick (never a half-cut state). + + ORCH-119 (FR-3 / AC-3): ``description`` (read from the tasks row by ``_spawn``, + durable since task creation) is passed through to ``_create_initial_docs`` so + the artifact materialised at claim carries the real request text, not ``TBD``. + The branch-cut MOMENT/CONDITION are unchanged — only the data source is enriched + (ORCH-088 anti-stale-base invariant preserved, NFR-3). """ import asyncio from ..webhooks.plane import _create_gitea_branch, _create_initial_docs @@ -535,7 +542,9 @@ class AgentLauncher: ) asyncio.run(_create_gitea_branch(repo, branch)) if work_item_id: - asyncio.run(_create_initial_docs(repo, branch, work_item_id, name)) + asyncio.run( + _create_initial_docs(repo, branch, work_item_id, name, description) + ) def _spawn(self, agent: str, repo: str, task_content: str = None, task_id: int = None, job_id: int = None) -> int: @@ -556,9 +565,14 @@ class AgentLauncher: raise FileNotFoundError(f"Repo not found: {local_repo_path}") # Determine branch (needed before we touch the worktree / task file). + # ORCH-119: also read the durable `description` so the deferred branch + # materialisation (path B) renders the real request text into + # 00-business-request.md instead of `TBD`. No network call (read from the + # tasks row) -> the hot claim path stays network-free (NFR-4). _br_row = ( get_db().execute( - "SELECT branch, work_item_id, title FROM tasks WHERE id=?", (task_id,) + "SELECT branch, work_item_id, title, description FROM tasks WHERE id=?", + (task_id,), ).fetchone() if task_id else None ) @@ -580,7 +594,7 @@ class AgentLauncher: _applies = False if _applies: self._materialize_deferred_branch( - repo, agent_branch, _br_row[1], _br_row[2] + repo, agent_branch, _br_row[1], _br_row[2], _br_row[3] ) # ORCH-41: resolve the Plane project uuid for this repo so per-project diff --git a/src/db.py b/src/db.py index 8bf1c1f..ecc2de7 100644 --- a/src/db.py +++ b/src/db.py @@ -123,6 +123,16 @@ def init_db(): # ("🛠️ ET-012 · "). Populated from the Plane work-item name at task # creation; falls back to the work_item_id when absent. Idempotent ALTER. _ensure_column(conn, "tasks", "title", "TEXT") + # ORCH-119 (08-data-requirements.md): durable source-backed Plane-issue + # `description` (plain-text, preferably description_stripped). Mirrors tasks.title + # 1:1 — additive, idempotent (_ensure_column is a no-op once present) -> safe on + # the live shared prod DB (enduro untouched). Written inside the atomic INSERT in + # create_task_atomic so it is race-safe vs the ORCH-053 anti-dup claim (no window + # where the task exists but the description is missing). The deferred branch cut + # (path B, ORCH-088, dominates on self-hosting) reads it from the tasks row at + # claim time and renders it into 00-business-request.md instead of the historic + # hardcoded `TBD`. NULL for pre-existing rows -> renders the safe fallback marker. + _ensure_column(conn, "tasks", "description", "TEXT") # Telegram live tracker: "BRD review" is the only HUMAN gate time — the delta # between "BRD ready / approve requested" and the analysis->architecture # advance (human flipped Plane to Approved). Persisted on the task so the @@ -651,6 +661,7 @@ def create_task_atomic( branch: str, stage: str, title: str, + description: str | None = None, ) -> tuple[dict, bool]: """ORCH-053 (AC-4): atomically claim creation of a task for a plane_id. @@ -665,6 +676,14 @@ def create_task_atomic( * ``created=False`` -> a task for this plane_id already existed (the other racer won); ``row`` is the existing task and the caller must NOT duplicate the follow-up work. + + ORCH-119 (ADR-001 D1): ``description`` (the source-backed Plane-issue text) is + persisted durable INSIDE this same atomic INSERT — never a separate UPDATE — so + there is no race window (ORCH-053) where the task exists but the description is + missing. The parameter is additive with a default so the other callers (e.g. the + F-2 reconciler) stay backward-compatible (NULL description -> render falls back to + a safe marker). The deferred branch cut (path B, ORCH-088) reads it from the row + at claim time. """ with _CREATE_TASK_LOCK: conn = get_db() @@ -677,9 +696,11 @@ def create_task_atomic( return dict(existing), False cur = conn.execute( "INSERT INTO tasks " - "(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (plane_id, work_item_id, repo, branch, stage, plane_id, title), + "(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title, " + "description) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (plane_id, work_item_id, repo, branch, stage, plane_id, title, + description), ) conn.commit() row = conn.execute( diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 417935c..0102b57 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -637,8 +637,12 @@ async def start_pipeline(data: dict, project_id: str = ""): # process-wide lock. If the F-2 reconciler and this live webhook race on the # same plane_id, exactly one wins (created=True); the loser sees the existing # task and returns WITHOUT creating a second branch / worktree / analyst job. + # ORCH-119 (FR-3 / AC-3): persist `description` DURABLE inside the same atomic + # INSERT (mirror of `title`) so the deferred branch cut (path B, ORCH-088 — dominates + # on self-hosting) can read it from the tasks row at claim time and render the real + # request text into 00-business-request.md instead of the hardcoded `TBD`. task_row, created = create_task_atomic( - plane_id, work_item_id, repo, branch, "analysis", name + plane_id, work_item_id, repo, branch, "analysis", name, description ) if not created: logger.info( @@ -706,8 +710,10 @@ async def start_pipeline(data: dict, project_id: str = ""): return # Create initial docs structure via Gitea API (create file) + # ORCH-119 (FR-2 / AC-2): direct path A — pass the source-backed `description` + # so the artifact is created with the real request text in one call. try: - await _create_initial_docs(repo, branch, work_item_id, name) + await _create_initial_docs(repo, branch, work_item_id, name, description) except Exception as e: logger.error(f"Failed to create initial docs: {e}") else: @@ -914,15 +920,59 @@ async def _create_gitea_branch(repo: str, branch: str): logger.info(f"Created branch '{branch}' in {owner}/{repo}") -async def _create_initial_docs(repo: str, branch: str, work_item_id: str, name: str): - """Create initial business request doc in the feature branch.""" +# ORCH-119 (FR-4 / AC-4): explicit safe marker used when the source description is +# empty / whitespace / None / unreadable — NOT the historic bare `TBD` bug. +_BUSINESS_REQUEST_FALLBACK = "_(описание отсутствует в источнике)_" + + +def _render_business_request( + work_item_id: str, name: str, description: str | None +) -> str: + """ORCH-119 (FR-1 / BR-1): render the body of ``00-business-request.md`` from the + source-backed Plane-issue ``description``. + + Pure & network-free so it is unit-testable without Gitea (TC-01/TC-02/TC-06). The + Description section carries the ACTUAL request text plain-text and verbatim — + multi-line content and markdown special chars are preserved (the doc is + informational and NOT gate-parsed, NFR-2); only surrounding whitespace is trimmed + for the emptiness check. Empty / whitespace / None / any failure degrades to an + explicit safe marker (``_BUSINESS_REQUEST_FALLBACK``) instead of the historic bare + ``TBD`` (FR-4 / AC-4); never raises. The header (``# Business Request: {name}``) + and ``Work Item ID`` are unchanged. + """ + try: + body = (description or "").strip() + if not body: + body = _BUSINESS_REQUEST_FALLBACK + except Exception: # noqa: BLE001 - never let rendering break task creation + body = _BUSINESS_REQUEST_FALLBACK + return ( + f"# Business Request: {name}\n\n" + f"Work Item ID: {work_item_id}\n\n" + f"## Description\n\n{body}\n" + ) + + +async def _create_initial_docs( + repo: str, branch: str, work_item_id: str, name: str, + description: str | None = None, +): + """Create initial business request doc in the feature branch. + + ORCH-119: the Description section now carries the source-backed Plane-issue + ``description`` (rendered via ``_render_business_request``) instead of the historic + hardcoded ``TBD``. ``description`` is additive with a default so existing callers / + a re-claim stay backward-compatible; empty/None degrades to a safe marker. + Idempotent: Gitea 422 (file already exists) -> no-op, the previously written body + is NOT overwritten (AC-6 / TC-06). + """ owner = settings.gitea_owner file_path = f"docs/work-items/{work_item_id}/00-business-request.md" url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/contents/{file_path}" headers = {"Authorization": f"token {settings.gitea_token}"} import base64 - content = f"# Business Request: {name}\n\nWork Item ID: {work_item_id}\n\n## Description\n\nTBD\n" + content = _render_business_request(work_item_id, name, description) encoded = base64.b64encode(content.encode()).decode() payload = { diff --git a/tests/test_orch119_business_request.py b/tests/test_orch119_business_request.py new file mode 100644 index 0000000..6f06519 --- /dev/null +++ b/tests/test_orch119_business_request.py @@ -0,0 +1,263 @@ +"""ORCH-119: source-backed ``00-business-request.md`` (fix the hardcoded ``TBD``). + +The Description section of ``00-business-request.md`` must carry the ACTUAL Plane-issue +``description`` instead of the historic hardcoded literal ``TBD``. Because the +self-hosting ``orchestrator`` repo cuts its branch lazily at analyst-job claim (the +deferred path B, ORCH-088), the description must be DURABLE-persisted on the ``tasks`` +row at creation time (mirror of ``tasks.title``) so it survives the gap between task +creation and claim. + +These tests are network-free: the Gitea ``contents`` POST (httpx) and the deferred +branch-cut coroutines are mocked; ``create_task_atomic`` runs against a real isolated +SQLite DB. Mapping to ``04-test-plan.yaml``: + + * TC-01 — MANDATORY regression: render contains the real description, not ``TBD``. + * TC-02 — fallback: empty/whitespace/None -> explicit safe marker, never raises. + * TC-03 — deferred path B: description persisted + rendered at materialisation. + * TC-04 — direct path A: ``_create_initial_docs`` writes the real description. + * TC-05 — schema backward-compat: ``_ensure_column`` is additive + idempotent. + * TC-06 — data integrity: multi-line markdown preserved; Gitea 422 -> no-op. + * TC-07 — anti-regression: gates / STAGE_TRANSITIONS / QG_CHECKS unchanged. +""" + +import asyncio +import base64 +import os +import tempfile +from types import SimpleNamespace + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch119_business_request.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db, create_task_atomic # noqa: E402 +from src.webhooks import plane # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + + +# --------------------------------------------------------------------------- +# TC-01 (MANDATORY regression): the rendered body carries the real request +# text, NOT the historic hardcoded ``TBD``. Red before the fix +# (``_render_business_request`` did not exist / body was ``TBD``). +# --------------------------------------------------------------------------- +def test_tc01_render_contains_real_description(): + desc = "Users need source-backed business requests captured from the Plane issue." + out = plane._render_business_request("ORCH-119", "Source-backed request", desc) + + # The real request text reaches the artifact body... + assert desc in out + # ...the header / Work Item ID are still present (unchanged contract)... + assert "# Business Request: Source-backed request" in out + assert "Work Item ID: ORCH-119" in out + # ...and the Description body is NOT the bare ``TBD`` bug. + body = out.split("## Description", 1)[1] + assert "TBD" not in body + + +# --------------------------------------------------------------------------- +# TC-02 (fallback): empty / whitespace / None description -> explicit safe +# marker (never the bare ``TBD`` bug), and the renderer never raises. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("desc", ["", " ", "\n\t \n", None]) +def test_tc02_fallback_marker_no_raise(desc): + out = plane._render_business_request("ORCH-119", "Name", desc) + assert "описание отсутствует в источнике" in out + body = out.split("## Description", 1)[1] + assert "TBD" not in body + + +# --------------------------------------------------------------------------- +# TC-03 (deferred path B / self-hosting): description is persisted DURABLE on +# the tasks row at creation and rendered into the artifact when the +# deferred branch is materialised at claim (launcher). +# --------------------------------------------------------------------------- +def test_tc03_deferred_path_persists_and_renders(monkeypatch): + desc = "A durable source-backed description for the deferred path B (claim-time cut)." + row, created = create_task_atomic( + "plane-b", "ORCH-201", "orchestrator", + "feature/ORCH-201-x", "analysis", "Title B", desc, + ) + assert created + + # Durable: description survives in the tasks row (readable without the network). + got = get_db().execute( + "SELECT description FROM tasks WHERE id=?", (row["id"],) + ).fetchone() + assert got[0] == desc + + captured = {} + + async def _branch_spy(repo, branch): + captured["branch_cut"] = (repo, branch) + + async def _docs_spy(repo, branch, work_item_id, name, description=None): + captured["description"] = description + + # _materialize_deferred_branch imports these names from webhooks.plane at call + # time, so patching the source module attributes intercepts them. + monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy) + monkeypatch.setattr(plane, "_create_initial_docs", _docs_spy) + + from src.agents.launcher import AgentLauncher + AgentLauncher()._materialize_deferred_branch( + "orchestrator", "feature/ORCH-201-x", "ORCH-201", "Title B", desc + ) + + assert captured.get("branch_cut") == ("orchestrator", "feature/ORCH-201-x") + assert captured.get("description") == desc + + +# --------------------------------------------------------------------------- +# TC-04 (direct path A / serial gate not applicable): _create_initial_docs +# writes the real description into the Gitea contents body. +# --------------------------------------------------------------------------- +def test_tc04_direct_path_renders_description(monkeypatch): + desc = "Direct path A description that must reach the artifact body verbatim." + captured = {} + + class _Resp: + status_code = 201 + + def raise_for_status(self): + pass + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None, headers=None, timeout=None): + captured["content"] = base64.b64decode(json["content"]).decode() + return _Resp() + + monkeypatch.setattr( + plane, "httpx", SimpleNamespace(AsyncClient=lambda *a, **k: _Client()) + ) + + asyncio.run( + plane._create_initial_docs("orchestrator", "feature/x", "ORCH-119", "Name", desc) + ) + + assert desc in captured["content"] + assert "## Description\n\nTBD\n" not in captured["content"] + + +# --------------------------------------------------------------------------- +# TC-05 (schema backward-compat): _ensure_column adds tasks.description on a +# pre-existing table without it; idempotent on re-run; creation works. +# --------------------------------------------------------------------------- +def test_tc05_schema_backward_compat(monkeypatch, tmp_path): + db_path = str(tmp_path / "bc.db") + monkeypatch.setattr(_db.settings, "db_path", db_path) + + # Simulate an OLD tasks table WITHOUT the description column. + conn = _db.get_db() + conn.executescript( + """ + CREATE TABLE tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plane_id TEXT, work_item_id TEXT, repo TEXT NOT NULL, + branch TEXT, stage TEXT DEFAULT 'created', plane_issue_id TEXT, title TEXT + ); + """ + ) + conn.commit() + conn.close() + + # init_db must add the column idempotently and never fail. + init_db() + cols = [r[1] for r in _db.get_db().execute("PRAGMA table_info(tasks)").fetchall()] + assert "description" in cols + + init_db() # re-run: _ensure_column is a no-op (idempotent) + + # Task creation works and persists the description. + row, created = create_task_atomic( + "p1", "ORCH-1", "orchestrator", "b", "analysis", "T", "a real description" + ) + assert created + got = get_db().execute( + "SELECT description FROM tasks WHERE id=?", (row["id"],) + ).fetchone() + assert got[0] == "a real description" + + +# --------------------------------------------------------------------------- +# TC-06 (data integrity): multi-line markdown with special chars is preserved +# verbatim (no truncation/escaping); Gitea 422 (file exists) -> no-op +# (single create attempt, body NOT overwritten). +# --------------------------------------------------------------------------- +def test_tc06_multiline_preserved_and_idempotent_422(monkeypatch): + desc = ( + "# Heading\n\n- bullet with `inline code`\n" + "- second *italic* and __bold__\n\n" + "Paragraph with special chars: <>&\"' and a trailing word." + ) + out = plane._render_business_request("ORCH-119", "N", desc) + assert desc in out # preserved verbatim, no truncation/escaping + + calls = [] + + class _Resp: + status_code = 422 # Gitea: file already exists + + def raise_for_status(self): + raise AssertionError("422 must be a no-op, never raised") + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None, headers=None, timeout=None): + calls.append(json) + return _Resp() + + monkeypatch.setattr( + plane, "httpx", SimpleNamespace(AsyncClient=lambda *a, **k: _Client()) + ) + + asyncio.run( + plane._create_initial_docs("orchestrator", "b", "ORCH-119", "N", desc) + ) + # Exactly one create attempt; no follow-up PUT/overwrite of the existing body. + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# TC-07 (anti-regression): the pipeline machinery is untouched — +# 00-business-request.md stays informational (not gate-parsed). +# --------------------------------------------------------------------------- +def test_tc07_gates_unchanged(): + from src.stages import STAGE_TRANSITIONS + from src.qg.checks import QG_CHECKS + + # Stage graph intact. + for st in ("created", "analysis", "architecture", "development", + "review", "testing", "deploy-staging", "deploy", "done"): + assert st in STAGE_TRANSITIONS + + # The named checks still exist with their canonical names. + for chk in ("check_analysis_complete", "check_architecture_done", + "check_ci_green", "check_tests_passed"): + assert chk in QG_CHECKS + + # 00-business-request.md is informational: no check is keyed on it. + assert not any("business" in k.lower() for k in QG_CHECKS) diff --git a/tests/test_serial_gate_branch.py b/tests/test_serial_gate_branch.py index b4aeb96..1d36110 100644 --- a/tests/test_serial_gate_branch.py +++ b/tests/test_serial_gate_branch.py @@ -68,7 +68,9 @@ async def _drive_start_pipeline(monkeypatch, gate_applies: bool): async def _branch_spy(repo, branch): branch_calls.append((repo, branch)) - async def _docs_spy(repo, branch, wi, name): + # ORCH-119: _create_initial_docs gained an additive `description` arg; the spy + # accepts it so the serial-gate invariant assertions below stay 1:1. + async def _docs_spy(repo, branch, wi, name, description=None): docs_calls.append((repo, branch, wi, name)) monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy)