Compare commits
10 Commits
fix/deploy
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1e5fff52 | ||
| d0a34249cc | |||
|
|
1baae81165 | ||
|
|
e856e0940b | ||
|
|
7bbab9c38b | ||
| a33a971c9c | |||
|
|
d0c604bc66 | ||
| 83f5020f94 | |||
|
|
757745a221 | ||
| 34894f4684 |
52
.env.staging.example
Normal file
52
.env.staging.example
Normal file
@@ -0,0 +1,52 @@
|
||||
# STAGING env for orchestrator-staging (port 8501).
|
||||
# Plane/Gitea tokens and sandbox project — configured in ORCH-32.
|
||||
# On Stage 1 (ORCH-31) you can copy from prod .env, changing only isolation-related keys.
|
||||
#
|
||||
# DO NOT COMMIT the real .env.staging — this file is the template only.
|
||||
# Create .env.staging on the server and fill in real values before starting staging.
|
||||
|
||||
# ── Plane ─────────────────────────────────────────────────────────────────────
|
||||
ORCH_PLANE_API_URL=http://localhost:8091
|
||||
ORCH_PLANE_API_TOKEN=<plane-api-token>
|
||||
ORCH_PLANE_WORKSPACE_SLUG=<workspace-slug>
|
||||
ORCH_PLANE_WEBHOOK_SECRET=<webhook-secret>
|
||||
|
||||
# Per-agent Plane bot tokens (authorship in Plane comments).
|
||||
# Leave empty to use ORCH_PLANE_API_TOKEN fallback.
|
||||
ORCH_PLANE_BOT_ANALYST=
|
||||
ORCH_PLANE_BOT_ARCHITECT=
|
||||
ORCH_PLANE_BOT_DEVELOPER=
|
||||
ORCH_PLANE_BOT_REVIEWER=
|
||||
ORCH_PLANE_BOT_TESTER=
|
||||
ORCH_PLANE_BOT_DEPLOYER=
|
||||
ORCH_PLANE_BOT_STREAM=
|
||||
|
||||
# ── Gitea ─────────────────────────────────────────────────────────────────────
|
||||
ORCH_GITEA_URL=http://localhost:3000
|
||||
ORCH_GITEA_PUBLIC_URL=https://git.mva154.duckdns.org
|
||||
ORCH_GITEA_TOKEN=<gitea-token>
|
||||
ORCH_GITEA_WEBHOOK_SECRET=<gitea-webhook-secret>
|
||||
|
||||
# ── Telegram ──────────────────────────────────────────────────────────────────
|
||||
ORCH_TELEGRAM_BOT_TOKEN=<telegram-bot-token>
|
||||
ORCH_TELEGRAM_CHAT_ID=<telegram-chat-id>
|
||||
|
||||
# ── Claude / repos ────────────────────────────────────────────────────────────
|
||||
ORCH_CLAUDE_BIN=/usr/bin/claude
|
||||
ORCH_REPOS_DIR=/repos
|
||||
ORCH_HOST_REPOS_DIR=/home/slin/repos
|
||||
|
||||
# ── Database (ISOLATION KEY for staging) ─────────────────────────────────────
|
||||
# The staging volume mounts ./data/staging:/app/data, so the DB physically lives
|
||||
# at ./data/staging/orchestrator.db on the host — fully isolated from prod.
|
||||
# Do NOT change this path; isolation is achieved via the volume mount, not this path.
|
||||
ORCH_DB_PATH=/app/data/orchestrator.db
|
||||
|
||||
# ── Concurrency / worker ──────────────────────────────────────────────────────
|
||||
ORCH_MAX_CONCURRENCY=1
|
||||
ORCH_QUEUE_POLL_INTERVAL=2.0
|
||||
|
||||
# ── Deploy hook ───────────────────────────────────────────────────────────────
|
||||
DEPLOY_SSH_USER=slin
|
||||
DEPLOY_SSH_HOST=127.0.0.1
|
||||
DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||
22
.gitea/workflows/ci.yml
Normal file
22
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: ["feature/**", "bugfix/**", "hotfix/**", "fix/**", "ci/**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip
|
||||
python3 -m pip install --user -r requirements.txt
|
||||
- name: Test
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m pytest tests/ -q
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,3 +5,7 @@ __pycache__/
|
||||
data/
|
||||
*.db
|
||||
.pytest_cache/
|
||||
# ORCH-31: staging env (secrets, not committed — see .env.staging.example)
|
||||
.env.staging
|
||||
# ORCH-31: staging DB data directory
|
||||
data/staging/
|
||||
|
||||
@@ -25,3 +25,39 @@ services:
|
||||
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||
group_add:
|
||||
- "999"
|
||||
|
||||
# ORCH-31: staging instance (port 8501, isolated DB).
|
||||
# Starts ONLY with: docker compose --profile staging up -d orchestrator-staging
|
||||
# Normal "docker compose up -d" does NOT start this service.
|
||||
orchestrator-staging:
|
||||
profiles:
|
||||
- staging
|
||||
build: .
|
||||
container_name: orchestrator-staging
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
network_mode: host
|
||||
command: ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8501"]
|
||||
volumes:
|
||||
- ./data/staging:/app/data
|
||||
- /home/slin/repos:/repos
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /usr/lib/node_modules/@anthropic-ai/claude-code:/opt/claude-code:ro
|
||||
- /usr/bin/node:/usr/bin/node:ro
|
||||
- /home/slin/.claude:/home/slin/.claude
|
||||
- /home/slin/.claude.json:/home/slin/.claude.json:ro
|
||||
- /home/slin/.orchestrator-ssh:/root/.ssh:ro
|
||||
env_file: .env.staging
|
||||
environment:
|
||||
- ORCH_REPOS_DIR=/repos
|
||||
- ORCH_HOST_REPOS_DIR=/home/slin/repos
|
||||
- DEPLOY_SSH_USER=slin
|
||||
- DEPLOY_SSH_HOST=127.0.0.1
|
||||
- DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh
|
||||
# Staging DB is isolated via ./data/staging volume mount.
|
||||
# Inside the container the path remains /app/data/orchestrator.db (same default),
|
||||
# but on the host it physically lives at ./data/staging/orchestrator.db —
|
||||
# completely separate from prod ./data/orchestrator.db.
|
||||
- ORCH_DB_PATH=/app/data/orchestrator.db
|
||||
group_add:
|
||||
- "999"
|
||||
|
||||
132
docs/PRODUCT_VISION.md
Normal file
132
docs/PRODUCT_VISION.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Product Vision — Автономная фабрика разработки (Orchestrator)
|
||||
|
||||
> Мультиагентная платформа, которая превращает идею или баг в задеплоенный на прод результат — автономно, надёжно и дёшево.
|
||||
|
||||
**Версия:** 1.0 · **Дата:** 2026-06-04 · **Статус:** концепция развития
|
||||
|
||||
---
|
||||
|
||||
## 1. Зачем это (бизнес-взгляд)
|
||||
|
||||
### Проблема
|
||||
Классическая разработка — это люди-бутылочное-горлышко на каждом шаге: аналитик, архитектор, разработчик, ревьюер, тестировщик, деплой-инженер. Каждая передача задачи между ними — потеря времени, контекста и денег. Мелкая фича или баг едут днями.
|
||||
|
||||
### Решение
|
||||
**Orchestrator** — это конвейер из ИИ-агентов, который проводит задачу через все стадии разработки сам: от бизнес-постановки до релиза на прод. Человек ставит задачу и принимает результат. Всё между — автономно.
|
||||
|
||||
### Ценность
|
||||
- ⚡ **Скорость:** фича проходит полный цикл (анализ → архитектура → код → ревью → тесты → деплой) за ~35 минут без ручных вмешательств.
|
||||
- 💰 **Стоимость:** работа агентов в разы дешевле команды; адаптивный выбор моделей экономит на простых задачах.
|
||||
- 🎯 **Автономность:** 0 ручных пинков в штатном прогоне. Человек — постановщик и приёмщик, а не оператор.
|
||||
- 🛡️ **Надёжность:** многоуровневые гейты качества не пускают недоделку на прод.
|
||||
- 🔁 **Масштаб:** одна платформа ведёт несколько проектов; саму платформу можно тиражировать на новые хосты.
|
||||
|
||||
---
|
||||
|
||||
## 2. Как это работает (обзор)
|
||||
|
||||
### Конвейер
|
||||
```
|
||||
created → analysis → architecture → development → review → testing → deploy → done
|
||||
```
|
||||
На каждом переходе стоит **quality gate** — автоматическая проверка, которая не пускает задачу дальше, пока стадия не выполнена честно:
|
||||
|
||||
| Переход | Гейт | Что проверяет |
|
||||
|---|---|---|
|
||||
| analysis → architecture | check_analysis_approved | BRD/TRZ/AC готовы + апрув человека |
|
||||
| architecture → development | check_architecture_done | Архитектура/ADR зафиксированы |
|
||||
| development → review | check_ci_green | CI зелёный (тесты проходят) |
|
||||
| review → testing | check_reviewer_verdict | Машинный вердикт ревьюера: APPROVED |
|
||||
| testing → deploy | check_tests_passed | Машинный вердикт тестера (не подделать) |
|
||||
| deploy → done | check_deploy_status | Деплой реально успешен, лог в origin/main |
|
||||
|
||||
### Агенты
|
||||
- **Analyst** — собирает бизнес-требования, пишет BRD/TRZ/критерии приёмки.
|
||||
- **Architect** — проектирует решение, фиксирует ADR.
|
||||
- **Developer** — пишет код в изолированном git-worktree.
|
||||
- **Reviewer** — ревьюит, выносит машинный вердикт.
|
||||
- **Tester** — прогоняет тесты, фиксирует результат в отчёте.
|
||||
- **Deployer** — мержит, тегирует, деплоит на прод, пишет deploy-log.
|
||||
|
||||
### Объекты
|
||||
- **Project** — проект в реестре (Plane project ↔ git-репозиторий ↔ префикс задач).
|
||||
- **Work-Item** — задача, проходящая конвейер; на каждой стадии накапливает артефакты (00-business-request … 14-deploy-log).
|
||||
- **Job** — единица работы в очереди (atomic claim, ретраи, restart-safe).
|
||||
|
||||
### Интеграции
|
||||
- **Plane** — управление задачами, статусы как триггеры конвейера, webhooks.
|
||||
- **Gitea** — репозитории, PR, защита main (pre-receive hook).
|
||||
- **Telegram** — живой трекер прогресса, апрувы, уведомления.
|
||||
- **LLM** — модели агентов (сейчас Claude, в планах мультипровайдерность).
|
||||
|
||||
---
|
||||
|
||||
## 3. Что уже сделано (фундамент)
|
||||
|
||||
✅ **Автономный конвейер** — подтверждён живым прогоном: задача от issue до Done без ручных вмешательств (~35 мин).
|
||||
✅ **Очередь задач** — atomic claim, max_concurrency, ретраи, restart-safe.
|
||||
✅ **Изоляция через git-worktree** — каждая задача в своём дереве, без конфликтов в shared-репо.
|
||||
✅ **Машинные гейты качества** — вердикты читаются из структурированных артефактов, а не угадываются по тексту.
|
||||
✅ **Multi-repo** — платформа ведёт несколько проектов (enduro-trails, сам orchestrator).
|
||||
✅ **Идемпотентность webhooks** — дедуп по delivery-id, защита от дублей.
|
||||
✅ **Наблюдаемость** — учёт токенов и стоимости каждой задачи.
|
||||
✅ **Живой Telegram-трекер** — прогресс редактируется в одном сообщении, без спама.
|
||||
|
||||
---
|
||||
|
||||
## 4. Куда движемся (дорожная карта)
|
||||
|
||||
Развитие сгруппировано в 5 стратегических направлений.
|
||||
|
||||
### 🛡️ Надёжность и безопасность
|
||||
- **Post-deploy мониторинг + авто-rollback** — следить за продом после релиза, откатывать при деградации.
|
||||
- **Security-гейт** — secret-scanning + аудит зависимостей перед мержем.
|
||||
- **Бюджетный circuit-breaker** — хард-лимит стоимости на задачу, защита от «убегающих» расходов.
|
||||
- **Опциональная human-приёмка** — финальный взгляд человека для критичных фич.
|
||||
|
||||
### 💰 Экономика и интеллект
|
||||
- **Мультипровайдерность LLM** — Claude, OpenRouter, другие провайдеры на выбор.
|
||||
- **Оценка задачи** — прогноз стоимости/времени до старта.
|
||||
- **Адаптивный выбор модели** — по сложности: тривиальное на дешёвой, сложное на сильной.
|
||||
- **Багфикс-трек** — упрощённый дешёвый путь для багов (без потери качества).
|
||||
|
||||
### 🏗️ Платформа и масштаб
|
||||
- **Self-hosting** — оркестратор пилит сам себя через собственный конвейер.
|
||||
- **Саморазвитие** — петля уроков: ловить отклонения → фиксировать → предлагать улучшения.
|
||||
- **Онбординг проектов** — turnkey-заведение нового проекта в систему.
|
||||
- **Тиражирование** — развернуть платформу на новой инфраструктуре под ключ.
|
||||
|
||||
### 💬 Взаимодействие с человеком
|
||||
- **UX/UI дизайнер** — макеты интерфейсов на этапе аналитики.
|
||||
- **Интерактивный аналитик** — живой диалог для уточнения требований и обсуждения макетов.
|
||||
- **Единые коммент-артефакты** — все агенты прикладывают результаты с кликабельными ссылками.
|
||||
- **Прямые ссылки в Telegram** — апрув в один клик, без блужданий.
|
||||
|
||||
### 🧩 Расширение возможностей
|
||||
- **Тяжёлые расчёты данных** — опциональная стадия для миграций/обработки больших данных.
|
||||
- **Android-разработка** — мобильный стек через тот же конвейер.
|
||||
- **Декомпозиция эпиков** — большая фича → подзадачи → сборка.
|
||||
- **Управление зависимостями** — задача B ждёт задачу A.
|
||||
- **Code coverage gate** — защита покрытия тестами от деградации.
|
||||
- **База знаний проекта** — персистентный контекст для агентов.
|
||||
|
||||
---
|
||||
|
||||
## 5. Принципы (что для нас неизменно)
|
||||
|
||||
1. **Автономность по умолчанию, человек — на ключевых развилках.** Машина делает, человек ставит и принимает.
|
||||
2. **Качество не приносится в жертву скорости/цене.** Удешевляем аналитику — гейты качества остаются. Урок дорого выученный: срезанная проверка = недоделка на проде.
|
||||
3. **Машинные вердикты, а не угадывание.** Гейты читают структурированные поля, а не ищут слова в тексте.
|
||||
4. **Самоизменение — только через PR + ревью + апрув.** Агент, меняющий агентов, всегда под контролем человека.
|
||||
5. **Документация — сразу, не потом.** Изменил функционал → обновил доки.
|
||||
6. **Прод — источник правды.** «Деплой прошёл» ≠ «работает». Проверяем реальный результат.
|
||||
|
||||
---
|
||||
|
||||
## 6. Видение в одну фразу
|
||||
|
||||
> **Самодостаточная фабрика разработки, которая размножается, учится на ошибках, оценивает себя, бережёт бюджет и не ломает прод — превращая намерение человека в работающий продукт почти без его участия.**
|
||||
|
||||
---
|
||||
|
||||
*Документ поддерживается в репозитории orchestrator. Источник дорожной карты — задачи проекта ORCH в Plane (ORCH-7…ORCH-28).*
|
||||
BIN
docs/PRODUCT_VISION.pptx
Normal file
BIN
docs/PRODUCT_VISION.pptx
Normal file
Binary file not shown.
85
docs/STAGING.md
Normal file
85
docs/STAGING.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Staging Environment (ORCH-31)
|
||||
|
||||
Orchestrator supports a permanent **staging instance** running on port **8501** with a
|
||||
fully-isolated SQLite database. The staging instance shares the same codebase and
|
||||
Dockerfile as production but is started under the `staging` Docker Compose profile so it
|
||||
**never starts accidentally** during a normal `docker compose up -d`.
|
||||
|
||||
## Architecture
|
||||
|
||||
| | Production | Staging |
|
||||
|---|---|---|
|
||||
| Port | 8500 | 8501 |
|
||||
| Container name | `orchestrator` | `orchestrator-staging` |
|
||||
| DB (host path) | `./data/orchestrator.db` | `./data/staging/orchestrator.db` |
|
||||
| DB (container path) | `/app/data/orchestrator.db` | `/app/data/orchestrator.db` |
|
||||
| env file | `.env` | `.env.staging` |
|
||||
| Compose profile | *(default)* | `staging` |
|
||||
|
||||
DB isolation is achieved via a separate volume mount (`./data/staging:/app/data`), not by
|
||||
changing `ORCH_DB_PATH` — the container path stays identical while the host path is a
|
||||
different directory.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **`.env.staging`** — create from the template (see below). This file is **not committed**
|
||||
to the repo (it contains secrets). Copy and fill in values before first start.
|
||||
2. **`./data/staging/`** directory — created automatically on first container start.
|
||||
|
||||
### Create `.env.staging`
|
||||
|
||||
```bash
|
||||
cd /home/slin/repos/orchestrator
|
||||
cp .env.staging.example .env.staging
|
||||
# Edit .env.staging — fill in real tokens / secrets.
|
||||
# At Stage 1 (ORCH-31) you can reuse prod values; sandbox Plane project
|
||||
# and isolated Gitea webhook will be wired in ORCH-32.
|
||||
nano .env.staging
|
||||
```
|
||||
|
||||
## Starting Staging
|
||||
|
||||
```bash
|
||||
cd /home/slin/repos/orchestrator
|
||||
docker compose --profile staging up -d orchestrator-staging
|
||||
```
|
||||
|
||||
Check it is running:
|
||||
|
||||
```bash
|
||||
docker ps | grep orchestrator-staging
|
||||
curl -s http://localhost:8501/health | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Stopping Staging
|
||||
|
||||
```bash
|
||||
docker compose --profile staging stop orchestrator-staging
|
||||
# or remove the container entirely:
|
||||
docker compose --profile staging down orchestrator-staging
|
||||
```
|
||||
|
||||
## Normal `up -d` does NOT start staging
|
||||
|
||||
```bash
|
||||
# This starts ONLY the prod orchestrator (port 8500). Staging is NOT affected.
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The `profiles: [staging]` directive in `docker-compose.yml` ensures staging is
|
||||
completely invisible to commands that do not pass `--profile staging`.
|
||||
|
||||
## Logs
|
||||
|
||||
```bash
|
||||
docker logs -f orchestrator-staging
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Task | Description |
|
||||
|---|---|
|
||||
| **ORCH-31** *(this PR)* | Infra: compose service, .env template, gitignore, docs |
|
||||
| **ORCH-32** | Sandbox: isolated Plane project + Gitea repo for staging |
|
||||
| **ORCH-33** | Test suite running against staging endpoint |
|
||||
| **ORCH-34** | Deploy hook: promote `orchestrator:candidate` image to staging |
|
||||
@@ -138,7 +138,16 @@ def check_review_approved(repo: str, pr_number: int) -> tuple[bool, str]:
|
||||
|
||||
def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
Check if test report exists and contains PASS indicator.
|
||||
Gate the testing -> deploy transition on the tester's MACHINE-READABLE verdict
|
||||
in 13-test-report.md frontmatter, NOT on a naive substring search of the body.
|
||||
|
||||
ET-013 fix: the previous implementation did `if "PASS" in content`, so a report
|
||||
explicitly marked `verdict: BLOCKED` / `status: blocked` but whose prose mentioned
|
||||
"23 passed" / "✅ PASS" / "All checks passed" was treated as a pass, and an
|
||||
unfinished feature reached Done. This mirrors check_reviewer_verdict (S-5) and
|
||||
check_deploy_status (БАГ 8): read ONLY the YAML frontmatter `verdict:` / `status:`
|
||||
fields, never the body.
|
||||
|
||||
File: docs/work-items/<work_item_id>/13-test-report.md
|
||||
"""
|
||||
repo_path = _repo_path(repo, branch)
|
||||
@@ -150,12 +159,67 @@ def check_tests_passed(repo: str, work_item_id: str, branch: str | None = None)
|
||||
try:
|
||||
with open(report_path, "r") as f:
|
||||
content = f.read()
|
||||
if "PASS" in content or "All tests passed" in content:
|
||||
return True, "Test report indicates PASS"
|
||||
return False, "Test report exists but no PASS indicator found"
|
||||
except OSError as e:
|
||||
return False, f"Error reading test report: {e}"
|
||||
|
||||
return _parse_tests_verdict(content)
|
||||
|
||||
|
||||
# Positive / negative verdict tokens, derived from REAL tester reports in
|
||||
# enduro-trails (ET-001..ET-014). The tester is inconsistent: most write
|
||||
# `verdict: PASS`, but ET-006 used `verdict: ready-to-deploy` (with `status: PASSED`),
|
||||
# ET-007 `verdict: PASS — ready-to-deploy`, ET-008 `verdict: stage:ready-to-deploy`
|
||||
# (with `status: pass`). ET-013 (the bug) used `verdict: BLOCKED` / `status: blocked`.
|
||||
# We therefore match known positive/negative TOKENS inside the normalized
|
||||
# verdict/status fields, and treat a negative token as authoritative (a BLOCKED/FAILED
|
||||
# report never passes, even if another field looks positive).
|
||||
_TESTS_NEGATIVE_TOKENS = ("BLOCKED", "FAILED", "FAIL", "REQUEST_CHANGES", "REJECT", "RED")
|
||||
_TESTS_POSITIVE_TOKENS = ("PASSED", "PASS", "READY-TO-DEPLOY", "READY_TO_DEPLOY", "GREEN", "APPROVED")
|
||||
|
||||
|
||||
def _parse_tests_verdict(content: str) -> tuple[bool, str]:
|
||||
"""Map a 13-test-report.md body to a quality-gate verdict by reading ONLY the
|
||||
machine-readable `verdict:` (and corroborating `status:`) YAML frontmatter fields.
|
||||
|
||||
Rules:
|
||||
- No frontmatter / bad YAML / neither field present -> (False, reason).
|
||||
- A negative token (BLOCKED/FAILED/...) in verdict OR status -> (False) and is
|
||||
authoritative (ET-013 main case: verdict BLOCKED wins over any prose PASS).
|
||||
- Otherwise a positive token (PASS/PASSED/READY-TO-DEPLOY/...) in verdict OR
|
||||
status -> (True).
|
||||
- Anything else (unrecognized / empty verdict) -> (False, reason).
|
||||
"""
|
||||
import yaml
|
||||
|
||||
if not content.startswith("---"):
|
||||
return False, "No YAML frontmatter in test report (cannot read machine verdict)"
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
return False, "Malformed YAML frontmatter in test report"
|
||||
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1]) or {}
|
||||
except yaml.YAMLError as e:
|
||||
return False, f"Invalid YAML frontmatter in test report: {e}"
|
||||
if not isinstance(fm, dict):
|
||||
return False, "Malformed YAML frontmatter in test report (not a mapping)"
|
||||
|
||||
verdict = str(fm.get("verdict", "") or "").upper().strip()
|
||||
status = str(fm.get("status", "") or "").upper().strip()
|
||||
|
||||
if not verdict and not status:
|
||||
return False, "No machine-readable verdict/status in test report frontmatter"
|
||||
|
||||
fields = f"{verdict} {status}"
|
||||
for neg in _TESTS_NEGATIVE_TOKENS:
|
||||
if neg in fields:
|
||||
return False, f"Test verdict: {verdict or status} ({neg})"
|
||||
for pos in _TESTS_POSITIVE_TOKENS:
|
||||
if pos in fields:
|
||||
return True, f"Test verdict: {verdict or status} (PASS)"
|
||||
|
||||
return False, f"No recognized PASS verdict in frontmatter (verdict={verdict!r}, status={status!r})"
|
||||
|
||||
|
||||
def check_analysis_approved(repo: str, work_item_id: str, branch: str | None = None) -> tuple[bool, str]:
|
||||
|
||||
@@ -38,3 +38,36 @@ def _no_telegram(monkeypatch):
|
||||
monkeypatch.setattr("src.agents.launcher.send_telegram", _noop, raising=False)
|
||||
monkeypatch.setattr("src.queue_worker.send_telegram", _noop, raising=False)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_webhook_secrets(monkeypatch):
|
||||
"""Isolate settings singleton between test files (CI cross-file isolation).
|
||||
|
||||
settings is a process-wide Pydantic singleton read once at import. Different
|
||||
test modules set env variables differently at import-time, so those values leak
|
||||
across files when pytest collects them together (as CI does).
|
||||
|
||||
1. webhook secrets: reset to "" so HMAC is disabled by default. Tests that
|
||||
intentionally test the 401 path (test_webhook_dedup.py:268,278) re-apply
|
||||
their own monkeypatch AFTER this autouse fixture runs, which overrides the
|
||||
reset for the duration of that one test only.
|
||||
|
||||
2. db_path: reset to the value from ORCH_DB_PATH env var (last written by the
|
||||
last imported test module). Without this, test_webhook_dedup.py (imported
|
||||
first, alphabetically) seeds settings.db_path = dedup.db, while
|
||||
test_webhooks.py's setup_db fixture tries to remove test_orchestrator.db,
|
||||
leaving the DB dirty across tests that share a branch name and causing
|
||||
get_task_by_repo_branch() to return a stale row with the wrong stage.
|
||||
Per-test monkeypatches in test_webhook_dedup.setup_db override this reset.
|
||||
"""
|
||||
import os
|
||||
from src.webhooks import gitea as gitea_mod
|
||||
from src.webhooks import plane as plane_mod
|
||||
from src import db as db_mod
|
||||
monkeypatch.setattr(gitea_mod.settings, "gitea_webhook_secret", "", raising=False)
|
||||
monkeypatch.setattr(plane_mod.settings, "plane_webhook_secret", "", raising=False)
|
||||
db_path_env = os.environ.get("ORCH_DB_PATH", "")
|
||||
if db_path_env:
|
||||
monkeypatch.setattr(db_mod.settings, "db_path", db_path_env, raising=False)
|
||||
yield
|
||||
|
||||
107
tests/test_qg.py
107
tests/test_qg.py
@@ -167,23 +167,110 @@ class TestCheckReviewApproved:
|
||||
|
||||
|
||||
class TestCheckTestsPassed:
|
||||
def test_report_with_pass(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: PASS\n")
|
||||
"""ET-013 fix: testing -> deploy gate reads the tester's MACHINE-READABLE verdict
|
||||
in 13-test-report.md frontmatter (verdict:/status:), NOT a substring of the body.
|
||||
Mirrors check_reviewer_verdict / check_deploy_status. The old `if "PASS" in content`
|
||||
let a `verdict: BLOCKED` report whose prose said "23 passed"/"✅ PASS" pass the gate,
|
||||
shipping an unfinished feature to Done."""
|
||||
|
||||
def _write(self, repo_dir, content, wi="ET-001"):
|
||||
wi_dir = repo_dir / "docs" / "work-items" / wi
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "13-test-report.md").write_text(content)
|
||||
|
||||
def test_verdict_pass_passes(self, setup_work_item_dir):
|
||||
# Most common real form (ET-001/002/005/009/011/012/014).
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\ntype: test-report\nverdict: PASS\nstatus: pass\n---\n\n# Test Report\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
assert "PASS" in reason
|
||||
|
||||
def test_verdict_pass_ready_to_deploy_passes(self, setup_work_item_dir):
|
||||
# ET-007 real form: "PASS — ready-to-deploy".
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: PASS — ready-to-deploy\nstatus: PASS\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_report_without_pass(self, setup_work_item_dir):
|
||||
repo_dir = setup_work_item_dir
|
||||
wi_dir = repo_dir / "docs" / "work-items" / "ET-001"
|
||||
wi_dir.mkdir(parents=True)
|
||||
(wi_dir / "13-test-report.md").write_text("# Test Report\n\nResult: FAIL\n")
|
||||
def test_verdict_ready_to_deploy_with_status_passed_passes(self, setup_work_item_dir):
|
||||
# ET-006 real form: verdict has no PASS word, but status: PASSED.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: ready-to-deploy\nstatus: PASSED\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_verdict_stage_ready_to_deploy_with_status_pass_passes(self, setup_work_item_dir):
|
||||
# ET-008 real form: verdict: stage:ready-to-deploy, status: pass.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: stage:ready-to-deploy\nstatus: pass\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is True
|
||||
|
||||
def test_blocked_verdict_with_pass_in_body_fails(self, setup_work_item_dir):
|
||||
# THE ET-013 BUG: verdict BLOCKED but body is full of "PASS"/"passed".
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\ntype: test-report\nstatus: blocked\nverdict: BLOCKED\n---\n\n"
|
||||
"23 passed\n✅ PASS (часть AC-18)\nAll checks passed\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "BLOCKED" in reason
|
||||
|
||||
def test_failed_verdict_fails(self, setup_work_item_dir):
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: FAILED\nstatus: failed\n---\n\nbody\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "FAILED" in reason
|
||||
|
||||
def test_passed_count_in_body_but_blocked_verdict_fails(self, setup_work_item_dir):
|
||||
# Body says "23 passed" but frontmatter verdict BLOCKED -> substring no longer fools.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: BLOCKED\n---\n\nTests: 23 passed, 0 failed.\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
def test_no_frontmatter_fails(self, setup_work_item_dir):
|
||||
# Old format / prose only -> no machine verdict -> fail.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"# Test Report\n\nResult: PASS\nAll tests passed.\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
def test_no_verdict_field_fails(self, setup_work_item_dir):
|
||||
# Frontmatter present but neither verdict nor status -> fail.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\ntype: test-report\nversion: 1\n---\n\nResult: PASS\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
|
||||
def test_invalid_yaml_fails_no_exception(self, setup_work_item_dir):
|
||||
# Broken YAML frontmatter -> False with reason, never raises.
|
||||
self._write(
|
||||
setup_work_item_dir,
|
||||
"---\nverdict: [unclosed\n : : :\n---\n\nbody PASS\n",
|
||||
)
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
assert passed is False
|
||||
assert "YAML" in reason or "frontmatter" in reason.lower()
|
||||
|
||||
def test_no_report(self, setup_work_item_dir):
|
||||
passed, reason = check_tests_passed("enduro-trails", "ET-001")
|
||||
|
||||
@@ -54,13 +54,19 @@ def test_status_endpoint():
|
||||
assert "active_tasks" in resp.json()
|
||||
|
||||
|
||||
@patch("src.plane_sync.add_comment")
|
||||
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=None)
|
||||
@patch("src.plane_sync.fetch_issue_fields", return_value=("Test task", "This is a detailed test description for the task"))
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
def test_plane_webhook_creates_task(mock_docs, mock_branch):
|
||||
"""work_item.created → task in DB with stage=analysis."""
|
||||
def test_plane_webhook_creates_task(mock_docs, mock_branch, mock_fetch_fields, mock_fetch_seq, mock_add_comment):
|
||||
"""work_item.created (via In Progress status) → task in DB with stage=analysis."""
|
||||
resp = client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "test-123", "name": "Test task", "project": "proj-1"}
|
||||
"event": "issue", "action": "updated",
|
||||
"data": {
|
||||
"id": "test-123", "name": "Test task", "project": "proj-1",
|
||||
"state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress", "group": "started"},
|
||||
}
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "accepted"
|
||||
@@ -75,17 +81,37 @@ def test_plane_webhook_creates_task(mock_docs, mock_branch):
|
||||
assert "feature/" in task["branch"]
|
||||
|
||||
|
||||
@patch("src.plane_sync.add_comment")
|
||||
@patch("src.plane_sync.fetch_issue_sequence_id", return_value=None)
|
||||
@patch("src.plane_sync.fetch_issue_fields",
|
||||
side_effect=[
|
||||
("First task", "This is a detailed description for the first task item"),
|
||||
("Second task", "This is a detailed description for the second task item"),
|
||||
])
|
||||
@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock)
|
||||
@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock)
|
||||
def test_plane_webhook_generates_sequential_ids(mock_docs, mock_branch):
|
||||
"""Multiple work items get sequential IDs."""
|
||||
def test_plane_webhook_generates_sequential_ids(
|
||||
mock_docs, mock_branch, mock_fetch_fields, mock_fetch_seq, mock_add_comment
|
||||
):
|
||||
"""Multiple In Progress transitions get sequential IDs (ET-001, ET-002)."""
|
||||
in_progress_state = {
|
||||
"id": "b873d9eb-993c-48cd-97ac-99a9b1623967",
|
||||
"name": "In Progress",
|
||||
"group": "started",
|
||||
}
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "item-1", "name": "First task", "project": "proj-1"}
|
||||
"event": "issue", "action": "updated",
|
||||
"data": {
|
||||
"id": "item-1", "name": "First task", "project": "proj-1",
|
||||
"state": in_progress_state,
|
||||
}
|
||||
})
|
||||
client.post("/webhook/plane", json={
|
||||
"event": "work_item.created",
|
||||
"data": {"id": "item-2", "name": "Second task", "project": "proj-1"}
|
||||
"event": "issue", "action": "updated",
|
||||
"data": {
|
||||
"id": "item-2", "name": "Second task", "project": "proj-1",
|
||||
"state": in_progress_state,
|
||||
}
|
||||
})
|
||||
|
||||
conn = get_db()
|
||||
@@ -202,8 +228,9 @@ def test_gitea_webhook_push():
|
||||
assert resp.json()["status"] == "accepted"
|
||||
|
||||
|
||||
@patch("src.webhooks.gitea.plane_notify_stage")
|
||||
@patch("src.webhooks.gitea.launcher")
|
||||
def test_gitea_push_with_adr_advances_stage(mock_launcher):
|
||||
def test_gitea_push_with_adr_advances_stage(mock_launcher, mock_plane_notify):
|
||||
"""Push with ADR files at architecture stage → advance to development."""
|
||||
mock_launcher.launch.return_value = 1
|
||||
|
||||
@@ -235,7 +262,7 @@ def test_gitea_push_with_adr_advances_stage(mock_launcher):
|
||||
task = conn.execute("SELECT * FROM tasks WHERE plane_id = 'push-001'").fetchone()
|
||||
conn.close()
|
||||
assert task["stage"] == "development"
|
||||
mock_launcher.launch.assert_called_once()
|
||||
mock_plane_notify.assert_called_once()
|
||||
|
||||
|
||||
@patch("src.webhooks.gitea.check_ci_green")
|
||||
|
||||
Reference in New Issue
Block a user