From 13e9618bd27052b374627fb38916d31906f4e0fa Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 15:34:46 +0300 Subject: [PATCH] developer(ET): auto-commit from developer run_id=587 --- docs/operations/ONBOARDING.md | 200 +++++++++ docs/operations/SETUP_WEBHOOKS.md | 20 +- tests/test_onboarding_invariants.py | 116 ++++++ tests/test_onboarding_kit.py | 414 +++++++++++++++++++ tests/test_onboarding_script.py | 605 ++++++++++++++++++++++++++++ 5 files changed, 1348 insertions(+), 7 deletions(-) create mode 100644 docs/operations/ONBOARDING.md create mode 100644 tests/test_onboarding_invariants.py create mode 100644 tests/test_onboarding_kit.py create mode 100644 tests/test_onboarding_script.py diff --git a/docs/operations/ONBOARDING.md b/docs/operations/ONBOARDING.md new file mode 100644 index 0000000..d6ff718 --- /dev/null +++ b/docs/operations/ONBOARDING.md @@ -0,0 +1,200 @@ +# ONBOARDING — turnkey-онбординг нового проекта (ORCH-009) + +> RUNBOOK. Полный чеклист подключения нового проекта к оркестратору одним проходом. +> Исполнитель — оператор; инструмент — CLI `scripts/onboard_project.py` +> (режимы `plan` — дефолт/dry-run, `apply`, `verify`). Каждый шаг, который CLI выполнить +> не может, помечен **🖐 РУЧНОЙ ШАГ** и снабжён командой проверки результата. +> Архитектура решения — см. «Ссылки» внизу. + +Запуск CLI — из корня чекаута репо orchestrator, в venv с `requirements.txt`: + +```bash +python3 scripts/onboard_project.py plan \ + --name "My Project" --description "зачем проект" \ + --repo my-project --prefix MP \ + --stack "Python 3.12 + FastAPI" --test-cmd "pytest tests/ -q" \ + --prod-port 8600 --staging-port 8601 \ + --webhook-url https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea +``` + +`plan` печатает полный план **без единой мутации** (ни сети-POST, ни записи на диск); +`apply` — идемпотентный ensure (существующее → `skipped(exists)`, ничего не удаляется); +exit-коды: `0` — чисто, `2` — есть `manual-step`/gap, `1` — ошибка. + +--- + +## 0. Предусловия + +Все значения — в `.env` на хосте (секреты в гит не попадают): + +| Переменная | Зачем | Проверка | +|-----------|-------|----------| +| `ORCH_PLANE_API_TOKEN` (+`ORCH_PLANE_API_URL`, `ORCH_PLANE_WORKSPACE_SLUG`) | создание/чтение проекта, статусов, лейблов | `curl -s -H "X-API-Key: $TOKEN" $URL/api/v1/workspaces/$SLUG/projects/ \| head -c 200` | +| `ORCH_GITEA_TOKEN` (+`ORCH_GITEA_URL`) | создание репо + webhook | `curl -s -H "Authorization: token $TOKEN" $URL/api/v1/user \| head -c 200` | +| `ORCH_GITEA_WEBHOOK_SECRET` | HMAC webhook (переиспользуется, один на все репо) | есть строка в `.env`; нет → `apply` сгенерирует и выведет | +| `ORCH_PROJECTS_JSON` | текущий реестр — источник merged-вывода | `grep ORCH_PROJECTS_JSON .env` | + +Токен Plane должен иметь право создавать проекты в workspace; токен Gitea — создавать репо и +hooks под выбранным owner (`--gitea-owner`, дефолт из конфига). + +--- + +## 1. Слой Plane: проект + статусы + лейблы + +Выполняет `apply` (или вручную при недоступности API CE — каждый отказ CLI помечает +`manual-step`, не падает). + +1. **Проект**: создаётся с `identifier = --prefix`. Уже существует → передай + `--plane-project-id ` (ensure распознает и пропустит). +2. **Статусы — точные канонические имена** (22, источник — `plane_sync._PLANE_NAME_TO_KEY`; + опечатка = тихая деградация fail-closed веток): + + | Статус | Группа | | Статус | Группа | + |--------|--------|-|--------|--------| + | Backlog | `backlog` | | In Review | `started` | + | Todo | `unstarted` | | Blocked | `started` | + | To Analyse | `unstarted` | | Approved | `started` | + | In Progress | `started` | | Rejected | `started` | + | Analysis | `started` | | **Confirm Deploy** | `started` | + | Architecture | `started` | | Needs Input | `started` | + | Development | `started` | | Done | `completed` | + | Code-Review | `started` | | Cancelled | `cancelled` | + | Review | `started` | | **STOP** | **`cancelled`** | + | Testing | `started` | | Awaiting Deploy | `started` | + | Deploying | `started` | | Monitoring after Deploy | `started` | + + ⚠️ Код-критично: `STOP` обязан быть в группе `cancelled` (иначе ветка отмены молча не + активируется); в терминальных группах (`completed`/`cancelled`) — ТОЛЬКО + Done/Cancelled/STOP, иначе terminal-detection ложно сочтёт живую задачу терминальной. +3. **Лейблы**: `autoApprove`, `autoDeploy`, `Bug` (имена — из конфига оркестратора; их + отсутствие = fail-safe ручной режим / полный цикл). +4. **🖐 РУЧНОЙ ШАГ — порядок статусов на доске**: drag-and-drop в UI (API не управляет + порядком). Проверка: открой доску проекта — колонки в порядке конвейера. +5. **Workspace-webhook**: уже **существует** (один на весь workspace, создан на уровне + workspace заранее) — CLI его НЕ создаёт, только напоминает проверить: + + ```bash + docker exec -e PGPASSWORD=plane plane-app-plane-db-1 psql -U plane -d plane -c \ + "SELECT id, url, is_active FROM webhooks;" + ``` + +--- + +## 2. Слой Gitea: репо + per-repo webhook + +1. **Репо** `--gitea-owner/--repo`: создаётся пустым (`auto_init=false`; ветку `main` создаст + initial push следующего слоя). Существует → `skipped(exists)`. +2. **Per-repo webhook**: `events: push/pull_request/status`, `content_type: json`, + `branch_filter: *`, URL = `--webhook-url`. **Секрет переиспользуется** из + `ORCH_GITEA_WEBHOOK_SECRET` (приёмник валидирует ОДИН глобальный секрет на все репо; + новый секрет сломал бы HMAC существующих вебхуков). Секрета нет в env → CLI сгенерирует и + выведет строку для `.env` — **🖐 РУЧНОЙ ШАГ**: добавить её в `.env` (в гит не коммитить). + Формат и проверка — `docs/operations/SETUP_WEBHOOKS.md`. Проверка: + + ```bash + curl -s -H "Authorization: token $ORCH_GITEA_TOKEN" \ + "$ORCH_GITEA_URL/api/v1/repos///hooks" | python3 -m json.tool + ``` +3. **Branch protection `main` НЕ включать** (ADR D10): required-approvals/status-checks ломают + PR-merge API merge-актора конвейера (ложные HOLD). Защита держится конвенцией + скоупом + токенов. + +--- + +## 3. Слой kit: материализация + initial push + +1. `apply` рендерит kit (`onboarding/repo-skeleton/`, плейсхолдеры `{{NAME}}` из + `onboarding/placeholders.json`) во временный каталог, докладывает live-copy канона + (`docs/_templates/` 16 скелетов + `docs/_standards/` 3 стандарта — verbatim из текущего + чекаута, BR-2 «канон не форкается») и пушит **ТОЛЬКО в свежесозданный/пустой репо** + (единственный разрешённый push; коммит `feat: onboarding skeleton (ORCH-009 kit)`). +2. Репо непустой → шаг помечается `manual-step`: **🖐 РУЧНОЙ ШАГ** — занеси недостающие + файлы обычным PR с ревью; поверх существующего контента ничего не пушится (BR-9). +3. После рендера не должно остаться ни одного `{{...}}`: CLI падает на этом сам; повторная + проверка — `verify` (скан плейсхолдеров в файлах репо). + +--- + +## 4. Регистрация в реестре оркестратора + +> ⚠️ **САМЫЙ ВАЖНЫЙ РУЧНОЙ СЛОЙ.** CLI `.env` прода НЕ правит и контейнер НЕ рестартит +> (инвариант NFR-2) — он только печатает готовую строку. + +1. **🖐 РУЧНОЙ ШАГ — env**: возьми из отчёта `apply` строку + `ORCH_PROJECTS_JSON=[...полный merged-массив...]` (существующие записи verbatim + новая в + конец; строка уже провалидирована фактическим парсером реестра) и замени ею строку в `.env` + оркестратора на хосте. Вставляется атомарно одной строкой — ручное слияние JSON не нужно. +2. **🖐 РУЧНОЙ ШАГ — управляемый рестарт оркестратора**: реестр строится при импорте, нужна + перезагрузка процесса. **Self-hosting предупреждение: прод-контейнер один на ВСЕ проекты — + рестарт = групповое окно** (встаёт конвейер всех проектов). Выполняй осознанно: дождись + тихого окна (`GET /queue` — нет бегущих job), затем штатный рестарт по + `docs/operations/INFRA.md`. Проверка после рестарта: + + ```bash + curl -s http://localhost:8500/health + curl -s http://localhost:8500/queue | python3 -m json.tool | head -30 # реестр жив, конвейер пуст/цел + ``` +3. TTL-self-heal статусов Plane (300с) рестарта НЕ требует: статусы/лейблы, созданные после + регистрации, подхватятся сами. + +--- + +## 5. Верификация + +1. **`verify`-режим CLI** (read-only): + + ```bash + python3 scripts/onboard_project.py verify --name ... --repo ... --prefix ... \ + --plane-project-id --stack ... --test-cmd ... --prod-port ... --staging-port ... \ + --webhook-url https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea + ``` + + Проверяет: запись реестра парсится и совпадает по полям; все 22 статуса резолвятся + (включая fail-closed `Confirm Deploy`/`STOP`); лейблы на месте; webhook существует и + активен; kit-файлы в репо (6 промптов, `AGENTS.md`, `INFRA.md`, `_templates`/`_standards`); + нет неразрешённых плейсхолдеров. Любой gap → exit `2` с перечнем. + +2. **Smoke на песочнице (ADR D8)** — контур: **staging-оркестратор (порт 8501, изолированная + БД `./data/staging`)** + одноразовый sandbox-проект (рекомендуемые имена: проект + `onboarding-smoke`, префикс `SMK`, репо `onboarding-smoke`): + 1. Онборди sandbox самим CLI (слои 1–3 этого runbook). + 2. **🖐 РУЧНОЙ ШАГ**: зарегистрируй sandbox в `ORCH_PROJECTS_JSON` **`.env.staging`** + (не прода!) и перезапусти staging-контейнер (он свободен от прод-инварианта): + `docker compose --profile staging up -d orchestrator-staging`. + 3. Создай тестовую задачу в sandbox-проекте → доведи до стадии analysis в песочнице. + 4. Критерий PASS: агент по своему промпту **прочитал доку проекта** (следы чтения + `CLAUDE.md`/`AGENTS.md` в выводе) и **записал артефакты** в `docs/work-items/SMK-…/` + по канону `PIPELINE_DOCS.md`. + 5. Запротоколируй прогон в «Журнале smoke-прогонов» (ниже). Для приёмки ORCH-009 первый + протокол обязателен. + +--- + +## 6. Откат + +CLI ничего не удаляет (BR-9) — откат всегда ручной и осознанный: + +| Что создано | Как откатить | Проверка | +|-------------|--------------|----------| +| Plane-проект (+статусы/лейблы) | удалить проект в UI Plane | проект исчез из списка workspace | +| Gitea-репо (+webhook) | удалить репо в UI/API Gitea (webhook умрёт вместе с ним) | `GET /api/v1/repos//` → 404 | +| Строка реестра | убрать запись из `ORCH_PROJECTS_JSON` в `.env` + управляемый рестарт (см. слой 4, то же групповое окно) | `GET /queue` — проекта нет в реестре | +| Sandbox-артефакты smoke | удалить sandbox-проект/репо после прогона (или архивировать) | см. выше | + +--- + +## Журнал smoke-прогонов + +| Дата | Оператор | Параметры (проект/префикс/репо) | Контур | Результат (PASS/FAIL) | Протокол | +|------|----------|----------------------------------|--------|------------------------|----------| +| — | — | — (первый прогон фиксируется при приёмке ORCH-009) | staging 8501 | — | — | + +--- + +## Ссылки + +- Архитектура решения: `docs/work-items/ORCH-009/06-adr/ADR-001-turnkey-onboarding-kit-and-cli.md` + (D1…D11); сквозной ADR — `docs/architecture/adr/adr-0035-turnkey-project-onboarding.md`. +- Устройство набора шаблонов и словарь плейсхолдеров: `onboarding/README.md`. +- Формат вебхуков: `docs/operations/SETUP_WEBHOOKS.md`; топология и рестарты — + `docs/operations/INFRA.md`. diff --git a/docs/operations/SETUP_WEBHOOKS.md b/docs/operations/SETUP_WEBHOOKS.md index 470396d..f8aa195 100644 --- a/docs/operations/SETUP_WEBHOOKS.md +++ b/docs/operations/SETUP_WEBHOOKS.md @@ -12,30 +12,36 @@ Internal URL: `http://127.0.0.1:8500/` --- -## Gitea Webhook +## Gitea Webhook (per-repo) -**Создан автоматически через API.** +Gitea-webhook — **per-repo**: создаётся для КАЖДОГО подключаемого к оркестратору репозитория +(`` ниже). Для новых проектов его создаёт onboarding-CLI +(`scripts/onboard_project.py apply`) — полный процесс см. `docs/operations/ONBOARDING.md`; +команды ниже — для ручной проверки/пересоздания на любом репо. - URL: `https://openclaw.mva154.duckdns.org/orchestrator/webhook/gitea` - Events: `push`, `pull_request`, `status` -- Secret: значение `ORCH_GITEA_WEBHOOK_SECRET` в `.env` +- Secret: значение `ORCH_GITEA_WEBHOOK_SECRET` в `.env` — **ОДИН глобальный секрет на все + репо** (приёмник валидирует только его; новый секрет на одном репо сломал бы HMAC остальных — + при ротации меняется на всех репо разом) - Signature header: `X-Gitea-Signature` (HMAC-SHA256 hex digest) ### Проверка ```bash GITEA_TOKEN=$(grep ORCH_GITEA_TOKEN /home/slin/repos/orchestrator/.env | cut -d= -f2) -curl -s "http://localhost:3000/api/v1/repos/admin/enduro-trails/hooks" \ +curl -s "http://localhost:3000/api/v1/repos///hooks" \ -H "Authorization: token ${GITEA_TOKEN}" | python3 -m json.tool ``` ### Пересоздание (если нужно) ```bash -GITEA_WEBHOOK_SECRET=$(openssl rand -hex 20) -# Обновить в .env: ORCH_GITEA_WEBHOOK_SECRET= +# Секрет переиспользуй из .env (ORCH_GITEA_WEBHOOK_SECRET); генерируй новый ТОЛЬКО при +# первичной настройке/осознанной ротации (и обнови вебхуки ВСЕХ репо): +GITEA_WEBHOOK_SECRET=$(grep ORCH_GITEA_WEBHOOK_SECRET /home/slin/repos/orchestrator/.env | cut -d= -f2) -curl -X POST "http://localhost:3000/api/v1/repos/admin/enduro-trails/hooks" \ +curl -X POST "http://localhost:3000/api/v1/repos///hooks" \ -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/json" \ -d '{ diff --git a/tests/test_onboarding_invariants.py b/tests/test_onboarding_invariants.py new file mode 100644 index 0000000..be815d8 --- /dev/null +++ b/tests/test_onboarding_invariants.py @@ -0,0 +1,116 @@ +"""ORCH-009 TC-21: pipeline invariants are untouched by the onboarding capability. + +The onboarding kit/CLI lives entirely OUTSIDE the runtime (NFR-1): `src/**` is +byte-for-byte untouched. These tests pin that contract: + +* a literal snapshot of ``STAGE_TRANSITIONS`` (the stage machine) and of the + ``QG_CHECKS`` registry — any drift fails loudly; +* ``src/**`` never references the onboarding tree (no runtime coupling); +* the CLI's read-only imports from ``src`` stay within the CLOSED list of + ADR-001 D4 (ORCH-009) — extending the list requires an ADR update; +* kit prompt templates name only real quality gates (no phantom ``check_*``). +""" +import ast +import os +import re + +from src.qg.checks import QG_CHECKS +from src.stages import STAGE_TRANSITIONS + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SCRIPT_PATH = os.path.join(_REPO_ROOT, "scripts", "onboard_project.py") +_KIT_AGENTS = os.path.join(_REPO_ROOT, "onboarding", "repo-skeleton", ".openclaw", "agents") + +# Literal snapshot of the stage machine (src/stages.py). Byte-exact NFR-1 pin: +# the onboarding work item must not move a single edge/agent/gate. +_EXPECTED_TRANSITIONS = { + "created": {"next": "analysis", "agent": "analyst", "qg": None}, + "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"}, + "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"}, + "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"}, + "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, + "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, + "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, + "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, + "done": {"next": None, "agent": None, "qg": None}, + "cancelled": {"next": None, "agent": None, "qg": None}, +} + +# Snapshot of the QG registry KEYS (src/qg/checks.py::QG_CHECKS). +_EXPECTED_QG_KEYS = { + "check_analysis_approved", + "check_analysis_complete", + "check_architecture_done", + "check_ci_green", + "check_review_approved", + "check_tests_passed", + "check_reviewer_verdict", + "check_tests_local", + "check_deploy_status", + "check_staging_status", + "check_branch_mergeable", + "check_staging_image_fresh", + "check_security_gate", + "check_coverage_gate", +} + +# Closed read-only import list of the onboarding CLI (ADR-001 D4 ORCH-009). +_ALLOWED_SRC_IMPORTS = {"src.config", "src.plane_sync", "src.projects"} + + +def test_tc21_stage_transitions_snapshot(): + assert STAGE_TRANSITIONS == _EXPECTED_TRANSITIONS, ( + "STAGE_TRANSITIONS drifted — ORCH-009 must not touch the stage machine (NFR-1)" + ) + + +def test_tc21_qg_checks_registry_snapshot(): + assert set(QG_CHECKS) == _EXPECTED_QG_KEYS, ( + "QG_CHECKS registry drifted — ORCH-009 must not touch the gates (NFR-1)" + ) + + +def test_tc21_src_never_references_onboarding(): + """No runtime coupling: src/** must not import/reference the onboarding tree.""" + offenders = [] + for root, _dirs, files in os.walk(os.path.join(_REPO_ROOT, "src")): + for name in files: + if not name.endswith(".py"): + continue + path = os.path.join(root, name) + with open(path, encoding="utf-8") as f: + if "onboard" in f.read().lower(): + offenders.append(os.path.relpath(path, _REPO_ROOT)) + assert not offenders, f"src/** references onboarding: {offenders}" + + +def test_tc21_cli_src_imports_stay_in_closed_list(): + """ADR-001 D4: the CLI may import ONLY src.config / src.plane_sync / src.projects.""" + with open(_SCRIPT_PATH, encoding="utf-8") as f: + tree = ast.parse(f.read()) + found = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module and node.module.startswith("src"): + found.add(node.module) + elif isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("src"): + found.add(alias.name) + assert found, "the CLI must use the closed src imports (round-trip via real parser)" + assert found <= _ALLOWED_SRC_IMPORTS, ( + f"onboard_project.py imports outside the closed ADR D4 list: " + f"{sorted(found - _ALLOWED_SRC_IMPORTS)} — extend ONLY via an ADR update" + ) + + +def test_tc21_kit_prompts_name_only_real_gates(): + """A kit prompt naming a phantom gate would mislead every onboarded project.""" + pattern = re.compile(r"check_[a-z_]+") + for name in sorted(os.listdir(_KIT_AGENTS)): + path = os.path.join(_KIT_AGENTS, name) + with open(path, encoding="utf-8") as f: + text = f.read() + for gate in sorted(set(pattern.findall(text))): + assert gate in QG_CHECKS, ( + f"kit prompt {name} references gate {gate!r} absent from QG_CHECKS" + ) diff --git a/tests/test_onboarding_kit.py b/tests/test_onboarding_kit.py new file mode 100644 index 0000000..83dc145 --- /dev/null +++ b/tests/test_onboarding_kit.py @@ -0,0 +1,414 @@ +"""ORCH-009: structural tests of the onboarding kit (`onboarding/repo-skeleton/`). + +Covers test-plan TC-01 (kit completeness), TC-03..TC-08 (prompt-template canon +52d/92), TC-19 (INFRA.md template sections) and TC-20 (ONBOARDING.md runbook). +Pure-text structural checks: NO network, NO agent runs (NFR-5). The kit prompt +templates are checked separately from the live orchestrator prompts +(`tests/test_agent_prompts_canon.py`) — the two trees must not be confused +(ADR-001 D1 ORCH-009). +""" +import json +import os +import re + +import pytest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_ONBOARDING = os.path.join(_REPO_ROOT, "onboarding") +_KIT = os.path.join(_ONBOARDING, "repo-skeleton") +_RUNBOOK = os.path.join(_REPO_ROOT, "docs", "operations", "ONBOARDING.md") + +_AGENTS = ("analyst", "architect", "developer", "reviewer", "tester", "deployer") + +# The 5 mandatory XML sections, in normative order (canon 52d, AC-2). +_SECTIONS = ("context", "task", "deliverables", "constraints", "output_format") + +# The 6 mandatory 52c schema fields (mirrors src/frontmatter.py::REQUIRED_FIELDS, +# kept literal here on purpose: kit tests must not import src/ — NFR-1 hygiene). +_SCHEMA_FIELDS = ("work_item", "stage", "author_agent", "status", "created_at", "model_used") + +# Role -> stage value(s) the template's example schema must pin (FR-2). +_STAGE_BY_ROLE = { + "analyst": ("analysis",), + "architect": ("architecture",), + "developer": ("development",), + "reviewer": ("review",), + "tester": ("testing",), + "deployer": ("deploy-staging", "deploy"), +} + + +def _read(*parts: str) -> str: + with open(os.path.join(_REPO_ROOT, *parts), encoding="utf-8") as f: + return f.read() + + +def _kit(*parts: str) -> str: + with open(os.path.join(_KIT, *parts), encoding="utf-8") as f: + return f.read() + + +def _prompt(agent: str) -> str: + return _kit(".openclaw", "agents", f"{agent}.md") + + +def _fenced_blocks(text: str) -> list[str]: + """Return the body of every ``` fenced code block (the *copyable* examples).""" + blocks: list[str] = [] + inside = False + buf: list[str] = [] + for line in text.splitlines(): + if line.lstrip().startswith("```"): + if inside: + blocks.append("\n".join(buf)) + buf = [] + inside = not inside + continue + if inside: + buf.append(line) + return blocks + + +# --------------------------------------------------------------------------- # +# TC-01 — kit completeness (AC-1 / FR-1) +# --------------------------------------------------------------------------- # + +_REQUIRED_FILES = [ + ".openclaw/agents/analyst.md", + ".openclaw/agents/architect.md", + ".openclaw/agents/developer.md", + ".openclaw/agents/reviewer.md", + ".openclaw/agents/tester.md", + ".openclaw/agents/deployer.md", + "CLAUDE.md", + "AGENTS.md", + "CONTRIBUTING.md", + "README.md", + "CHANGELOG.md", + ".env.example", + "docs/ARCHITECTURE.md", + "docs/PIPELINE.md", + "docs/PRODUCT_VISION.md", + "docs/operations/INFRA.md", + "docs/architecture/adr/README.md", + "docs/work-items/.gitkeep", + "docs/history/.gitkeep", +] + + +def test_tc01_kit_contains_all_required_elements(): + """TC-01: every FR-1 element of the skeleton is present (6 prompts + carcass).""" + missing = [ + rel for rel in _REQUIRED_FILES + if not os.path.isfile(os.path.join(_KIT, *rel.split("/"))) + ] + assert not missing, f"onboarding/repo-skeleton is missing: {missing}" + + +def test_tc01_kit_readme_and_placeholder_dictionary_exist(): + """TC-01/D1: onboarding/README.md + placeholders.json (single source of truth).""" + assert os.path.isfile(os.path.join(_ONBOARDING, "README.md")) + payload = json.loads(_read("onboarding", "placeholders.json")) + assert isinstance(payload, dict) and payload, "placeholders.json must be a non-empty dict" + for name, meta in payload.items(): + assert re.fullmatch(r"[A-Z][A-Z0-9_]*", name), f"bad placeholder name {name!r}" + for key in ("description", "required", "default", "example"): + assert key in meta, f"placeholders.json[{name}] lacks {key!r}" + + +def test_kit_does_not_fork_the_canon(): + """BR-2/D3: no second editable copy of the canon inside the kit. + + `docs/_templates/` and `docs/_standards/` are live-copied by the script at + materialisation time and must NOT be stored in the skeleton. + """ + for forbidden in ("docs/_templates", "docs/_standards"): + assert not os.path.exists(os.path.join(_KIT, *forbidden.split("/"))), ( + f"kit must not store an editable canon copy: {forbidden}" + ) + + +# --------------------------------------------------------------------------- # +# D2 — placeholder dictionary bijection (declared <-> used) +# --------------------------------------------------------------------------- # + +_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z][A-Z0-9_]*)\}\}") + + +def _kit_files() -> list[str]: + out = [] + for root, _dirs, files in os.walk(_KIT): + for name in files: + out.append(os.path.join(root, name)) + return out + + +def test_placeholder_dictionary_bijection(): + """D2: every placeholder used in the kit is declared, every declared is used.""" + declared = set(json.loads(_read("onboarding", "placeholders.json"))) + used: set[str] = set() + for path in _kit_files(): + with open(path, encoding="utf-8") as f: + used.update(_PLACEHOLDER_RE.findall(f.read())) + assert used == declared, ( + f"placeholder drift: used-not-declared={sorted(used - declared)}, " + f"declared-not-used={sorted(declared - used)}" + ) + + +# --------------------------------------------------------------------------- # +# TC-03 — 5 XML sections in normative order (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc03_five_xml_sections_in_normative_order(agent): + """Real section tags sit on their own line; inline backticked mentions + (e.g. «см. ``» inside ) must not be mistaken for them + (same disambiguation as the ORCH-092 check).""" + text = _prompt(agent) + positions = [] + for section in _SECTIONS: + open_m = re.search(rf"(?m)^<{section}>\s*$", text) + close_m = re.search(rf"(?m)^\s*$", text) + assert open_m, f"kit {agent}.md missing <{section}> on its own line" + assert close_m, f"kit {agent}.md missing on its own line" + positions.append(open_m.start()) + assert positions == sorted(positions), ( + f"kit {agent}.md sections out of normative order " + f"context→task→deliverables→constraints→output_format" + ) + + +# --------------------------------------------------------------------------- # +# TC-04 — at dev/reviewer/tester; bans in «❌ → ✅» form (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", ("developer", "reviewer", "tester")) +def test_tc04_escalation_section_after_success_criteria(agent): + text = _prompt(agent) + open_m = re.search(r"(?m)^\s*$", text) + close_m = re.search(r"(?m)^\s*$", text) + assert open_m and close_m, f"kit {agent}.md is missing the section" + success_m = re.search(r"(?m)^\s*$", text) + assert success_m and open_m.start() > success_m.start(), ( + f"kit {agent}.md must place after " + ) + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc04_bans_use_cross_check_format(agent): + text = _prompt(agent) + assert "❌" in text and "✅" in text, ( + f"kit {agent}.md must format bans as «❌ X → ✅ Y»" + ) + + +# --------------------------------------------------------------------------- # +# TC-05 — each template directs the agent to the project docs (AC-2 / BR-3) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc05_prompt_directs_agent_to_docs(agent): + text = _prompt(agent) + for marker in ( + "CLAUDE.md", # passport, read BEFORE work + "AGENTS.md", # docs map / entry point + "docs/ARCHITECTURE.md", # architecture doc + "docs/work-items/", # artefact home + "PIPELINE_DOCS.md", # docs standard + "docs/_templates/", # skeletons + ): + assert marker in text, f"kit {agent}.md does not reference {marker!r}" + + +@pytest.mark.parametrize("agent", ("developer", "reviewer")) +def test_tc05_changelog_duty_present(agent): + assert "CHANGELOG.md" in _prompt(agent), ( + f"kit {agent}.md must carry the CHANGELOG update duty" + ) + + +def test_tc05_architect_carries_adr_rules(): + text = _prompt("architect") + assert "06-adr/" in text, "kit architect.md must route decisions to 06-adr/" + assert "docs/architecture/adr/" in text, ( + "kit architect.md must carry the cross-cutting ADR rule" + ) + + +# --------------------------------------------------------------------------- # +# TC-06 — 52c schema emission + byte-exact machine-verdict keys (AC-2) +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_six_schema_fields_named(agent): + text = _prompt(agent) + for field in _SCHEMA_FIELDS: + assert field in text, f"kit {agent}.md does not mention schema field {field!r}" + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_schema_pins_role_author_and_stage(agent): + text = _prompt(agent) + assert f"author_agent: {agent}" in text, ( + f"kit {agent}.md does not pin 'author_agent: {agent}'" + ) + for stage in _STAGE_BY_ROLE[agent]: + assert f"stage: {stage}" in text, f"kit {agent}.md does not pin 'stage: {stage}'" + + +def test_tc06_machine_verdict_keys_byte_exact(): + reviewer = _prompt("reviewer") + assert "verdict:" in reviewer + assert "APPROVED" in reviewer and "REQUEST_CHANGES" in reviewer + + tester = _prompt("tester") + assert "result:" in tester + assert "PASS" in tester and "FAIL" in tester + + deployer = _prompt("deployer") + assert "staging_status:" in deployer + assert "deploy_status:" in deployer + assert "security_status:" in deployer + assert "SUCCESS" in deployer and "FAILED" in deployer + + +@pytest.mark.parametrize("agent", _AGENTS) +def test_tc06_dates_and_models_are_placeholders(agent): + """Anti-pattern ORCH-092: no literal date/model inside copyable examples.""" + text = _prompt(agent) + assert "created_at: " in text, ( + f"kit {agent}.md must use the created_at: placeholder" + ) + assert "date +%F" in text, ( + f"kit {agent}.md must instruct to substitute the actual date (date +%F)" + ) + for block in _fenced_blocks(text): + assert re.search(r"created_at:\s*\d", block) is None, ( + f"kit {agent}.md hardcodes a literal created_at date in a copyable block" + ) + assert re.search(r"model_used:\s*claude", block) is None, ( + f"kit {agent}.md hardcodes a literal model in a copyable block" + ) + + +# --------------------------------------------------------------------------- # +# TC-07 — reviewer-gate on documentation (AC-3 / BR-4) +# --------------------------------------------------------------------------- # + +def test_tc07_reviewer_gate_docs_not_updated_means_request_changes(): + text = _prompt("reviewer") + assert "REQUEST_CHANGES" in text + assert "НЕ обновлена" in text, ( + "kit reviewer.md must carry the mandatory gate: docs NOT updated -> " + "verdict: REQUEST_CHANGES" + ) + + +# --------------------------------------------------------------------------- # +# TC-08 — language policy: 5 ru + deployer en (AC-4 / D9) +# --------------------------------------------------------------------------- # + +_CYRILLIC = re.compile(r"[а-яА-ЯёЁ]") + + +@pytest.mark.parametrize("agent", ("analyst", "architect", "developer", "reviewer", "tester")) +def test_tc08_ru_canon_for_five_roles(agent): + assert _CYRILLIC.search(_prompt(agent)), ( + f"kit {agent}.md must follow the ru canon (ADR-001 D9 ORCH-009)" + ) + + +def test_tc08_deployer_is_english(): + text = _prompt("deployer") + assert not _CYRILLIC.search(text), ( + "kit deployer.md must stay 100% English (safety-critical canon, D9)" + ) + assert "Do NOT translate" in text, ( + "kit deployer.md must carry the language-note guard" + ) + + +# --------------------------------------------------------------------------- # +# TC-19 — INFRA.md template: mandatory sections (AC-10 / FR-3) +# --------------------------------------------------------------------------- # + +def test_tc19_infra_template_mandatory_sections(): + text = _kit("docs", "operations", "INFRA.md") + assert "Топология" in text, "INFRA template lacks the topology section" + assert "{{PROD_PORT}}" in text and "{{STAGING_PORT}}" in text, ( + "INFRA template must parametrise prod/staging ports" + ) + assert "env" in text.lower(), "INFRA template lacks the env map section" + assert ".env.example" in text, "INFRA template lacks the .env.example canon rule" + assert "Границы доступа" in text, "INFRA template lacks the access-boundaries section" + assert "общего хоста" in text or "общий хост" in text, ( + "INFRA template lacks the shared-host risk warnings" + ) + assert "секрет" in text.lower(), "INFRA template lacks the secrets rule" + + +def test_tc19_orchestrator_own_infra_untouched_sections(): + """AC-10: the orchestrator's own INFRA.md keeps its self-hosting warnings.""" + own = _read("docs", "operations", "INFRA.md") + assert "orchestrator" in own and "8500" in own, ( + "docs/operations/INFRA.md of the orchestrator must stay the self-hosting runbook" + ) + + +# --------------------------------------------------------------------------- # +# TC-20 — runbook ONBOARDING.md covers all layers in order (AC-11 / FR-6) +# --------------------------------------------------------------------------- # + +def test_tc20_runbook_exists_and_layer_order(): + assert os.path.isfile(_RUNBOOK), "docs/operations/ONBOARDING.md is missing" + text = _read("docs", "operations", "ONBOARDING.md") + # All BR-1 layers, in sequence. + anchors = ["Предусловия", "Plane", "Gitea", "kit", "Регистрация", "Верификация", "Откат"] + positions = [] + for anchor in anchors: + idx = text.find(anchor) + assert idx != -1, f"ONBOARDING.md lacks the {anchor!r} layer" + positions.append(idx) + assert positions == sorted(positions), ( + f"ONBOARDING.md layers out of order: {anchors}" + ) + + +def test_tc20_runbook_manual_steps_and_selfhosting_warning(): + text = _read("docs", "operations", "ONBOARDING.md") + assert "ручной шаг" in text.lower() or "РУЧНОЙ ШАГ" in text, ( + "ONBOARDING.md must explicitly mark manual steps" + ) + assert "рестарт" in text.lower(), ( + "ONBOARDING.md must describe the operator-managed restart step" + ) + assert "self-hosting" in text or "групповое окно" in text, ( + "ONBOARDING.md must warn that a prod restart is a group-wide window" + ) + # Plane workspace-webhook already exists: verify, never create (Ф-6). + assert "workspace" in text.lower(), "ONBOARDING.md must cover the workspace webhook" + assert "существует" in text, ( + "ONBOARDING.md must state the Plane workspace-webhook already exists" + ) + + +def test_tc20_runbook_verification_and_smoke_journal(): + text = _read("docs", "operations", "ONBOARDING.md") + assert "verify" in text, "ONBOARDING.md must document the verify mode" + assert "8501" in text, "ONBOARDING.md smoke contour must be staging (8501) — D8" + assert "Журнал smoke-прогонов" in text, ( + "ONBOARDING.md must carry the smoke-run journal section (D8)" + ) + assert "onboard_project.py" in text, "ONBOARDING.md must reference the CLI" + + +def test_setup_webhooks_generalised(): + """TRZ §2: SETUP_WEBHOOKS.md is generalised per-repo + references the runbook.""" + text = _read("docs", "operations", "SETUP_WEBHOOKS.md") + assert "ONBOARDING.md" in text, ( + "SETUP_WEBHOOKS.md must reference docs/operations/ONBOARDING.md" + ) + assert "" in text or "{repo}" in text, ( + "SETUP_WEBHOOKS.md per-repo section must be generalised, not enduro-hardcoded" + ) diff --git a/tests/test_onboarding_script.py b/tests/test_onboarding_script.py new file mode 100644 index 0000000..559bcb1 --- /dev/null +++ b/tests/test_onboarding_script.py @@ -0,0 +1,605 @@ +"""ORCH-009: tests of the operator onboarding CLI (`scripts/onboard_project.py`). + +Covers test-plan TC-02 (live-copy of the canon), TC-09..TC-11 (render / +anti-leak / referential integrity), TC-12 (registry round-trip through the +actual parser), TC-13..TC-16 (plan: Plane/Gitea completeness + dry-run with +zero mutations), TC-17..TC-18 (idempotent & safe apply). + +All tests are deterministic and offline (NFR-5): the Plane/Gitea clients are +replaced with in-memory fakes; git is replaced with a recording runner. The +script module is loaded via importlib (pattern: tests/test_staging_check_b6.py). +""" +import importlib.util +import json +import os +import re + +import pytest + +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_SCRIPT_PATH = os.path.join(_REPO_ROOT, "scripts", "onboard_project.py") + + +def _load_module(): + spec = importlib.util.spec_from_file_location("onboard_project", _SCRIPT_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(scope="module") +def mod(): + return _load_module() + + +_WEBHOOK_URL = "https://orchestrator.example.org/webhook/gitea" + + +def _params(**over): + p = { + "PROJECT_NAME": "Demo Project", + "PROJECT_DESCRIPTION": "Демо-проект для проверки онбординга", + "REPO": "demo-project", + "GITEA_OWNER": "admin", + "WORK_ITEM_PREFIX": "DEMO", + "PLANE_PROJECT_ID": "11111111-2222-3333-4444-555555555555", + "STACK": "Python 3.12 + FastAPI + SQLite", + "TEST_CMD": "pytest tests/ -q", + "PROD_PORT": "8600", + "STAGING_PORT": "8601", + } + p.update(over) + return p + + +def _step(report, step_id): + matches = [s for s in report.steps if s.id == step_id] + assert matches, f"report has no step {step_id!r}: {[s.id for s in report.steps]}" + return matches[0] + + +# --------------------------------------------------------------------------- # +# Fakes — the only network touchpoints of the script, replaced in-memory. +# --------------------------------------------------------------------------- # + +class FakePlane: + def __init__(self, mod, project=None, states=None, labels=None, + refuse_create_states=False, refuse_create_labels=False, + refuse_create_project=False): + self._mod = mod + self.project = project + self.states = list(states or []) + self.labels = list(labels or []) + self.mutations = [] + self.refuse_create_states = refuse_create_states + self.refuse_create_labels = refuse_create_labels + self.refuse_create_project = refuse_create_project + + # GET probes + def get_project(self, project_id): + if self.project and self.project.get("id") == project_id: + return self.project + return None + + def find_project_by_identifier(self, identifier): + if self.project and self.project.get("identifier") == identifier: + return self.project + return None + + def list_states(self, project_id): + return list(self.states) + + def list_labels(self, project_id): + return list(self.labels) + + # mutations + def create_project(self, name, identifier): + if self.refuse_create_project: + raise self._mod.ManualStep("Plane CE: projects API not available") + self.mutations.append(("create_project", name, identifier)) + self.project = {"id": "plane-uuid-created", "name": name, "identifier": identifier} + return self.project + + def create_state(self, project_id, name, group): + if self.refuse_create_states: + raise self._mod.ManualStep("Plane CE: states API not available") + self.mutations.append(("create_state", name, group)) + state = {"id": f"uuid-{name}", "name": name, "group": group} + self.states.append(state) + return state + + def create_label(self, project_id, name): + if self.refuse_create_labels: + raise self._mod.ManualStep("Plane CE: labels API not available") + self.mutations.append(("create_label", name)) + label = {"id": f"uuid-{name}", "name": name} + self.labels.append(label) + return label + + +class FakeGitea: + def __init__(self, repo=None, hooks=None, files=None): + self.repo = repo + self.hooks = list(hooks or []) + self.files = dict(files or {}) # repo path -> text (for verify) + self.mutations = [] + + def get_repo(self, owner, repo): + return self.repo + + def list_hooks(self, owner, repo): + return list(self.hooks) + + def create_repo(self, owner, name, description=""): + self.mutations.append(("create_repo", owner, name)) + self.repo = {"name": name, "owner": {"login": owner}, "empty": True} + return self.repo + + def create_hook(self, owner, repo, url, secret, events): + self.mutations.append(("create_hook", url, tuple(events))) + hook = {"id": 1, "active": True, "config": {"url": url}, "events": list(events)} + self.hooks.append(hook) + return hook + + # verify helpers + def get_file_text(self, owner, repo, path): + return self.files.get(path) + + def list_dir(self, owner, repo, path): + prefix = path.rstrip("/") + "/" + names = { + rel[len(prefix):].split("/", 1)[0] + for rel in self.files + if rel.startswith(prefix) + } + return sorted(names) or None + + +def _full_states(mod): + return [ + {"id": f"uuid-{name}", "name": name, "group": group} + for name, group in mod.STATE_GROUPS.items() + ] + + +def _full_labels(mod): + return [{"id": f"uuid-{name}", "name": name} for name in mod.label_names()] + + +# --------------------------------------------------------------------------- # +# TC-02 — materialisation live-copies the canon (BR-2 / D3) +# --------------------------------------------------------------------------- # + +def test_tc02_materialise_live_copies_canon(mod, tmp_path): + dest = tmp_path / "repo" + written = mod.materialize_kit(_params(), str(dest)) + assert written, "materialize_kit wrote nothing" + + templates = os.listdir(dest / "docs" / "_templates") + standards = os.listdir(dest / "docs" / "_standards") + assert len(templates) >= 16, f"expected >=16 canonical skeletons, got {len(templates)}" + assert len(standards) >= 3, f"expected >=3 standards, got {len(standards)}" + + # Verbatim copy — byte-equal to the live canon of the orchestrator checkout. + for rel in ("PIPELINE_DOCS.md", "HANDOFF_PROTOCOL.md", "TRACEABILITY.md"): + src_path = os.path.join(_REPO_ROOT, "docs", "_standards", rel) + with open(src_path, encoding="utf-8") as f: + canon = f.read() + with open(dest / "docs" / "_standards" / rel, encoding="utf-8") as f: + copied = f.read() + assert copied == canon, f"{rel} must be live-copied verbatim (BR-2)" + + +# --------------------------------------------------------------------------- # +# TC-09 / TC-10 — render: no unresolved placeholders, no orc leaks (AC-5) +# --------------------------------------------------------------------------- # + +def test_tc09_render_resolves_all_placeholders(mod): + rendered = mod.render_kit_in_memory(_params()) + assert rendered, "render_kit_in_memory returned nothing" + for rel, content in rendered.items(): + unresolved = mod.find_unresolved(content) + assert not unresolved, f"{rel} keeps unresolved placeholders: {unresolved}" + + +def test_tc10_no_orchestrator_specific_leaks(mod): + rendered = mod.render_kit_in_memory(_params()) + joined = "\n".join(rendered.values()) + assert re.search(r"ORCH-\d", joined) is None, ( + "rendered kit leaks an ORCH-NNN work-item literal where the project " + "prefix belongs (TC-10)" + ) + assert "8500" not in joined and "8501" not in joined, ( + "rendered kit leaks the orchestrator prod/staging ports" + ) + assert "self-hosting" not in joined.lower(), ( + "rendered kit leaks the orchestrator self-hosting rules" + ) + # The project's own parameters actually got substituted. + assert "DEMO-" in joined, "the project's work-item prefix was not substituted" + assert "demo-project" in joined, "the repo name was not substituted" + assert "8600" in joined and "8601" in joined, "ports were not substituted" + + +def test_render_is_a_pure_replace(mod): + text = "prefix {{WORK_ITEM_PREFIX}}-12 on port {{PROD_PORT}}" + out = mod.render(text, {"WORK_ITEM_PREFIX": "AB", "PROD_PORT": "9000"}) + assert out == "prefix AB-12 on port 9000" + assert mod.find_unresolved("a {{LEFT_OVER}} b") == ["{{LEFT_OVER}}"] + + +# --------------------------------------------------------------------------- # +# TC-11 — referential integrity of rendered prompts/AGENTS.md (AC-5) +# --------------------------------------------------------------------------- # + +_PATH_TOKEN = re.compile( + r"(?:docs/[\w./\-]+|\.openclaw/agents/[\w.\-]+|CLAUDE\.md|AGENTS\.md|" + r"CONTRIBUTING\.md|README\.md|CHANGELOG\.md|\.env\.example)" +) + + +def _static_paths(text: str) -> set[str]: + out = set() + for token in _PATH_TOKEN.findall(text): + token = token.rstrip(".,;:)`'\"") + # dynamic/illustrative tokens are not checkable paths + if any(ch in token for ch in "<>*{}") or "NNN" in token: + continue + out.add(token) + return out + + +def test_tc11_referenced_paths_exist_in_materialised_tree(mod, tmp_path): + dest = tmp_path / "repo" + mod.materialize_kit(_params(), str(dest)) + + sources = [ + os.path.join(dest, ".openclaw", "agents", f"{a}.md") + for a in ("analyst", "architect", "developer", "reviewer", "tester", "deployer") + ] + sources.append(os.path.join(dest, "AGENTS.md")) + + broken = [] + for src_file in sources: + with open(src_file, encoding="utf-8") as f: + for ref in _static_paths(f.read()): + target = os.path.join(dest, *ref.split("/")) + if not (os.path.isfile(target) or os.path.isdir(target.rstrip("/"))): + broken.append((os.path.relpath(src_file, dest), ref)) + assert not broken, f"kit references non-existent paths: {broken}" + + +# --------------------------------------------------------------------------- # +# TC-12 — registry round-trip through the ACTUAL parser (AC-6) +# --------------------------------------------------------------------------- # + +def test_tc12_registry_round_trip_through_actual_parser(mod): + from src.projects import _parse_projects_json + + existing = [ + { + "plane_project_id": "7a79f0a9-5278-49cd-9007-9a338f238f9c", + "repo": "enduro-trails", + "work_item_prefix": "ET", + "name": "enduro-trails", + }, + { + "plane_project_id": "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a", + "repo": "orchestrator", + "work_item_prefix": "ORCH", + "name": "orchestrator", + }, + ] + params = _params() + entry = mod.build_registry_entry(params) + standalone, merged = mod.merged_projects_json(entry, json.dumps(existing)) + + # standalone entry parses on its own + solo = _parse_projects_json(f"[{standalone}]") + assert solo is not None and len(solo) == 1 + + parsed = _parse_projects_json(merged) + assert parsed is not None and len(parsed) == 3, "merged registry must carry all 3" + # existing entries survive verbatim (no loss, no distortion) + for i, exp in enumerate(existing): + assert parsed[i].plane_project_id == exp["plane_project_id"] + assert parsed[i].repo == exp["repo"] + assert parsed[i].work_item_prefix == exp["work_item_prefix"] + assert parsed[i].name == exp["name"] + # the new entry carries the source params + new = parsed[2] + assert new.plane_project_id == params["PLANE_PROJECT_ID"] + assert new.repo == params["REPO"] + assert new.work_item_prefix == params["WORK_ITEM_PREFIX"] + assert new.name == params["PROJECT_NAME"] + + +def test_tc12_merge_is_idempotent_no_duplicates(mod): + from src.projects import _parse_projects_json + + params = _params() + entry = mod.build_registry_entry(params) + once = json.dumps([entry]) + _standalone, merged = mod.merged_projects_json(entry, once) + parsed = _parse_projects_json(merged) + assert parsed is not None and len(parsed) == 1, ( + "re-merging an already-registered project must not duplicate it" + ) + + +# --------------------------------------------------------------------------- # +# TC-13 — plan: exact Plane statuses (22) + groups + labels (AC-7) +# --------------------------------------------------------------------------- # + +def test_state_groups_match_plane_name_to_key(mod): + from src.plane_sync import _PLANE_NAME_TO_KEY + + assert set(mod.STATE_GROUPS) == set(_PLANE_NAME_TO_KEY), ( + "STATE_GROUPS must cover exactly the canonical Plane status names" + ) + # Code-critical constraints (ADR-001 D5): STOP joins the cancelled group + # (ORCH-090 fail-closed cancel); only Done/Cancelled/STOP are terminal — + # otherwise terminal-detection (ORCH-068) falsely terminates live tasks. + assert mod.STATE_GROUPS["STOP"] == "cancelled" + assert mod.STATE_GROUPS["Done"] == "completed" + assert mod.STATE_GROUPS["Cancelled"] == "cancelled" + terminal = {n for n, g in mod.STATE_GROUPS.items() if g in ("completed", "cancelled")} + assert terminal == {"Done", "Cancelled", "STOP"} + + +def test_tc13_plan_covers_all_statuses_and_labels(mod): + from src.plane_sync import _PLANE_NAME_TO_KEY + + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + + for name in _PLANE_NAME_TO_KEY: + step = _step(report, f"plane.state:{name}") + assert step.status == mod.PLANNED, f"status {name!r} not planned: {step.status}" + stop = _step(report, "plane.state:STOP") + assert "cancelled" in stop.detail, "STOP step must pin the cancelled group" + + for label in mod.label_names(): + assert _step(report, f"plane.label:{label}").status == mod.PLANNED + assert set(mod.label_names()) == {"autoApprove", "autoDeploy", "Bug"} + + # known UI-only steps are flagged manual, never silently dropped (D5) + assert _step(report, "plane.board-order").status == mod.MANUAL + assert _step(report, "plane.workspace-webhook").status == mod.MANUAL + + +# --------------------------------------------------------------------------- # +# TC-14 — Plane API refusal degrades to manual-step, never a crash (AC-7) +# --------------------------------------------------------------------------- # + +def test_tc14_plane_refusal_becomes_manual_step(mod, tmp_path): + plane = FakePlane( + mod, + project={"id": _params()["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + refuse_create_states=True, + refuse_create_labels=True, + ) + gitea = FakeGitea( + repo={"name": "demo-project", "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + ) + report = mod.run_apply( + _params(), plane, gitea, + webhook_url=_WEBHOOK_URL, git_runner=lambda cmd, cwd: 0, + workdir=str(tmp_path), + ) + state_steps = [s for s in report.steps if s.id.startswith("plane.state:")] + assert state_steps and all(s.status == mod.MANUAL for s in state_steps), ( + "refused Plane state creation must degrade to manual-step" + ) + for s in state_steps: + assert "ONBOARDING.md" in s.detail, "manual-step must link the runbook" + label_steps = [s for s in report.steps if s.id.startswith("plane.label:")] + assert label_steps and all(s.status == mod.MANUAL for s in label_steps) + assert report.exit_code == 2, "manual steps -> exit code 2" + + +# --------------------------------------------------------------------------- # +# TC-15 / TC-16 — plan: Gitea layer complete; dry-run mutates NOTHING (AC-8) +# --------------------------------------------------------------------------- # + +def test_tc15_plan_contains_gitea_repo_webhook_and_push(mod): + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + + assert _step(report, "gitea.repo").status == mod.PLANNED + hook = _step(report, "gitea.webhook") + assert hook.status == mod.PLANNED + for event in ("push", "pull_request", "status"): + assert event in hook.detail, f"webhook plan must name event {event!r}" + assert "HMAC" in hook.detail or "secret" in hook.detail.lower(), ( + "webhook plan must mention the HMAC secret (kept out of git)" + ) + push = _step(report, "kit.push") + assert push.status == mod.PLANNED + assert "push" in push.detail.lower() + + +def test_tc16_plan_is_a_pure_dry_run(mod, monkeypatch): + plane = FakePlane(mod) + gitea = FakeGitea() + + def _boom(*_a, **_kw): # plan must never materialise or push + raise AssertionError("plan mode touched the disk / git") + + monkeypatch.setattr(mod, "materialize_kit", _boom) + monkeypatch.setattr(mod, "initial_push", _boom) + + report = mod.run_plan(_params(), plane, gitea, webhook_url=_WEBHOOK_URL) + assert plane.mutations == [], "plan made a Plane mutation" + assert gitea.mutations == [], "plan made a Gitea mutation" + assert report.steps, "plan produced an empty report" + + +def test_secret_never_leaks_into_report(mod): + plane = FakePlane(mod) + gitea = FakeGitea() + report = mod.run_plan( + _params(), plane, gitea, webhook_url=_WEBHOOK_URL, + webhook_secret="super-secret-hmac-value", + ) + dumped = json.dumps(report.to_dict(), ensure_ascii=False) + assert "super-secret-hmac-value" not in dumped, ( + "the webhook HMAC secret leaked into the report (NFR-3)" + ) + + +# --------------------------------------------------------------------------- # +# TC-17 — apply is idempotent: existing entities -> skipped(exists) (AC-9) +# --------------------------------------------------------------------------- # + +def test_tc17_second_apply_skips_everything_existing(mod, tmp_path): + params = _params() + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=_full_states(mod), + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 7, "active": True, "config": {"url": _WEBHOOK_URL}}], + ) + git_calls = [] + report = mod.run_apply( + params, plane, gitea, webhook_url=_WEBHOOK_URL, + git_runner=lambda cmd, cwd: git_calls.append((cmd, cwd)) or 0, + workdir=str(tmp_path), + ) + + assert plane.mutations == [], "idempotent apply must not re-create Plane entities" + assert gitea.mutations == [], "idempotent apply must not re-create Gitea entities" + assert git_calls == [], "apply must NEVER push into a non-empty existing repo" + + assert _step(report, "plane.project").status == mod.SKIPPED + for name in mod.STATE_GROUPS: + assert _step(report, f"plane.state:{name}").status == mod.SKIPPED + for label in mod.label_names(): + assert _step(report, f"plane.label:{label}").status == mod.SKIPPED + assert _step(report, "gitea.repo").status == mod.SKIPPED + assert _step(report, "gitea.webhook").status == mod.SKIPPED + assert _step(report, "kit.push").status == mod.MANUAL, ( + "non-empty repo -> kit push degrades to a manual step, never an overwrite" + ) + + summary = report.to_dict() + for key in ("created", "skipped", "manual"): + assert key in summary["totals"], f"report totals lack {key!r}" + + +# --------------------------------------------------------------------------- # +# TC-18 — apply runs no restarts / no prod-.env edits / git only (NFR-2) +# --------------------------------------------------------------------------- # + +def test_tc18_fresh_apply_runs_git_only_inside_workdir(mod, tmp_path): + params = _params() + plane = FakePlane(mod) + gitea = FakeGitea() + calls = [] + + def recorder(cmd, cwd): + calls.append((list(cmd), cwd)) + return 0 + + report = mod.run_apply( + params, plane, gitea, webhook_url=_WEBHOOK_URL, + git_runner=recorder, workdir=str(tmp_path), + ) + + assert calls, "fresh empty repo: the initial push pipeline must run" + for cmd, cwd in calls: + assert cmd[0] == "git", f"only git may be executed, got: {cmd}" + assert cwd and str(tmp_path) in cwd, ( + f"git must run only inside the materialisation workdir, got cwd={cwd}" + ) + joined = " ".join(" ".join(c) for c, _ in calls) + assert "docker" not in joined and "restart" not in joined + + assert _step(report, "kit.push").status == mod.CREATED + assert ("create_repo", "admin", "demo-project") in gitea.mutations + hook_calls = [m for m in gitea.mutations if m[0] == "create_hook"] + assert hook_calls and hook_calls[0][1] == _WEBHOOK_URL + assert set(hook_calls[0][2]) == {"push", "pull_request", "status"} + + +def test_tc18_source_has_no_container_or_env_mutation_ops(mod): + with open(_SCRIPT_PATH, encoding="utf-8") as f: + source = f.read() + lowered = source.lower() + assert "docker" not in lowered, "the script must not touch containers (NFR-2)" + assert "systemctl" not in lowered + assert "compose" not in lowered + assert re.search(r"open\([^)]*\.env[^)]*['\"][wa]", source) is None, ( + "the script must never WRITE any .env (read-only access allowed)" + ) + + +# --------------------------------------------------------------------------- # +# verify — registry / states / labels / webhook / kit completeness (FR-5) +# --------------------------------------------------------------------------- # + +def _verify_files(mod): + params = _params() + rendered = mod.render_kit_in_memory(params) + files = dict(rendered) + for i in range(16): + files[f"docs/_templates/{i:02d}-skeleton.md"] = "x" + for name in ("PIPELINE_DOCS.md", "HANDOFF_PROTOCOL.md", "TRACEABILITY.md"): + files[f"docs/_standards/{name}"] = "x" + return files + + +def test_verify_all_green(mod): + params = _params() + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=_full_states(mod), + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + files=_verify_files(mod), + ) + entry = mod.build_registry_entry(params) + _, merged = mod.merged_projects_json(entry, "[]") + report = mod.run_verify( + params, plane, gitea, webhook_url=_WEBHOOK_URL, projects_raw=merged, + ) + gaps = [s for s in report.steps if s.status == mod.GAP] + assert not gaps, f"verify reported gaps on a fully onboarded project: {gaps}" + + +def test_verify_flags_missing_failclosed_statuses(mod): + params = _params() + states = [s for s in _full_states(mod) if s["name"] not in ("STOP", "Confirm Deploy")] + plane = FakePlane( + mod, + project={"id": params["PLANE_PROJECT_ID"], "identifier": "DEMO"}, + states=states, + labels=_full_labels(mod), + ) + gitea = FakeGitea( + repo={"name": params["REPO"], "empty": False}, + hooks=[{"id": 1, "active": True, "config": {"url": _WEBHOOK_URL}}], + files=_verify_files(mod), + ) + entry = mod.build_registry_entry(params) + _, merged = mod.merged_projects_json(entry, "[]") + report = mod.run_verify( + params, plane, gitea, webhook_url=_WEBHOOK_URL, projects_raw=merged, + ) + states_step = _step(report, "verify.plane.states") + assert states_step.status == mod.GAP + assert "STOP" in states_step.detail and "Confirm Deploy" in states_step.detail, ( + "verify must name the missing fail-closed statuses explicitly" + ) + assert report.exit_code == 2