diff --git a/.env.example b/.env.example index 9f413e1..6c81285 100644 --- a/.env.example +++ b/.env.example @@ -213,3 +213,10 @@ ORCH_POST_DEPLOY_FAIL_THRESHOLD=3 ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5 ORCH_POST_DEPLOY_AUTO_ROLLBACK=false ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500 + +# ── QG-0 entry validation (ORCH-069) ────────────────────────────────────────── +# Upper title-length limit for the QG-0 entry gate (_qg0_errors). The old 80-char +# cap was a hygiene limit, not structural (slug is cut to [:30] independently, the +# DB title TEXT is unbounded). Default 200. An invalid/empty value gracefully +# degrades to 200 (the process never crashes on startup). +ORCH_QG0_TITLE_MAX=200 diff --git a/.env.staging.example b/.env.staging.example index f3af589..722ed25 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -50,3 +50,6 @@ ORCH_QUEUE_POLL_INTERVAL=2.0 DEPLOY_SSH_USER=slin DEPLOY_SSH_HOST=127.0.0.1 DEPLOY_HOOK_SCRIPT=/home/slin/bin/enduro-deploy-hook.sh + +# QG-0 entry title-length limit (ORCH-069). Default 200; invalid/empty -> 200. +ORCH_QG0_TITLE_MAX=200 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b37bec..5184332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Конфигурируемый верхний лимит длины заголовка QG-0 (`ORCH_QG0_TITLE_MAX`, дефолт 200)** (ORCH-069): хардкод `if len(name) > 80` во входной валидации `_qg0_errors` (`src/webhooks/plane.py`) вынесен в настраиваемый параметр `Settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). Лимит 80 был гигиеническим, а не структурным (slug режется независимо `[:30]`, `tasks.title TEXT` без ограничения), поэтому валидные заголовки 81–200 символов отклонялись на входе без бизнес-причины. Лимит читается из `settings.qg0_title_max` динамически на каждый вызов (тесты патчат значение), текст ошибки подставляет актуальное число; граница строгая (`len > limit` → FAIL, `len == limit` → PASS). **Graceful-деградация (AC-3, self-hosting safety):** пустое/нечисловое значение env не роняет процесс на старте — `field_validator(mode="before")` `_qg0_title_max_default` в `src/config.py` перехватывает сырое env ДО `int`-парсинга pydantic и при невалидном/пустом входе возвращает дефолт 200 (never-raise), гася `ValidationError`. Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят (AC-7). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (QG-0 — inline-валидация входа, не зарегистрированный stage-gate), схема БД, slug-логика `[:30]`, нижние лимиты (`< 5` title, `< 20` description), soft-QG-0 поведение (warning на `work_item.created`), API. ADR `docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md`. Документация: `.env.example`, `.env.staging.example`. Тесты: `tests/test_qg0_title_limit.py`. ### Added - **Telegram live-tracker: `bump` по умолчанию + статус-строка Plane + кликабельный номер задачи** (ORCH-067): три улучшения карточки задачи (`src/notifications.py`), без изменения транспорта/схемы БД/`STAGE_TRANSITIONS`/QG. (1) **Дефолт `tracker_mode` сменён `edit → bump`** (`src/config.py`): актуальный статус всегда последним сообщением в чате при активной переписке; `edit` остаётся доступен через `ORCH_TRACKER_MODE=edit`. Логика `update_task_tracker` (best-effort `delete_telegram(old)` → `send_telegram(..., disable_notification=True)` → `set_tracker_message_id` только при успешном send) и инвариант «одна карточка на задачу» сохранены. (2) **Статус-строка карточки** `📍 ` по статусной модели ORCH-066: чистый/детерминированный, never-raise хелпер `plane_status_label(task_row)` (любая ошибка → дефолт по `stage`, рендер не ломается). Оффлайн-ядро (`stage → Plane-статус`; `⏸️ In Review` из brd-clock; `⏸️ Awaiting Deploy`) работает всегда без сети; ветки, неотличимые offline (`❓ Needs Input`, `Blocked`, `Rejected`, `Cancelled`, `Deploying`, `Monitoring after Deploy`), дорисовывает **best-effort live-overlay** `_live_plane_branch_override` — читает живой Plane-статус (reverse-map UUID→имя) с kill-switch'ем, per-issue TTL-кэшем и коротким таймаутом; недоступность сети/ответа → тихая деградация на stage-маппинг, конвейер НИКОГДА не блокируется (ADR Р-2/Р-3/Р-4). (3) **Кликабельный номер задачи**: единый never-raise хелпер `plane_issue_link(work_item_id, plane_issue_id, project_id, repo)` → `ORCH-NNN`, переиспользует guard'ы ORCH-017 (`_plane_issue_url`, loopback-base → «нет web URL»); fail-safe (не хватает web_base/workspace/project_id/issue_id) → `html.escape(work_item_id)` (номер без ссылки). Применён в заголовке карточки (`render_task_tracker` дочитывает `repo`/`plane_issue_id` из `tasks`, схема не менялась) и во всех точках `send_telegram`/`notify_*`, где в тексте есть `work_item_id` (`notify_approve_requested`/`notify_error`, `stage_engine.py`, `agents/launcher.py`, `merge_gate.py`, `job_reaper.py`, `security_gate.py`, `reconciler.py`, `main.py` — ровно где упоминается номер). Новые настройки: `ORCH_TRACKER_LIVE_STATUS` (true, kill-switch), `ORCH_TRACKER_LIVE_STATUS_TTL_S` (60), `ORCH_TRACKER_LIVE_STATUS_TIMEOUT_S` (3). Самохостинг: смена дефолта `bump` затрагивает ВСЕ проекты — проверено отсутствие регресса (тесты + staging). ADR `docs/work-items/ORCH-067/06-adr/ADR-001-tracker-plane-status-and-link.md`. Документация: `CLAUDE.md` (раздел «Нотификации / Telegram live-tracker»), `docs/architecture/README.md`, `docs/architecture/internals.md` (§7), `.env.example`. diff --git a/README.md b/README.md index e7de2cd..4036e94 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` | | `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` | | `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` | F-1 Guard 2 (ORCH-060): пропуск задач в Plane-статусе Blocked / Needs Input; `false` глушит только сетевой Guard 2 (Guard 1 escalated всегда активен) | `true` | +| `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` | ## Очередь задач (ORCH-1 / F-2b) diff --git a/docs/work-items/ORCH-069/00-business-request.md b/docs/work-items/ORCH-069/00-business-request.md new file mode 100644 index 0000000..dbce07c --- /dev/null +++ b/docs/work-items/ORCH-069/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 + +## Description + +TBD diff --git a/docs/work-items/ORCH-069/01-brd.md b/docs/work-items/ORCH-069/01-brd.md new file mode 100644 index 0000000..99e1a38 --- /dev/null +++ b/docs/work-items/ORCH-069/01-brd.md @@ -0,0 +1,76 @@ +# BRD — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 +Тип: Enhancement (QoL / конфигурируемость) +Источник: Слава, 2026-06-08 +Связано с: QG-0 (gate входа конвейера, `_qg0_errors`) + +## 1. Проблема (As-Is) +QG-0 — первый quality gate конвейера. Он валидирует заголовок и описание задачи +до старта pipeline (`start_pipeline`) и в soft-режиме на `work_item.created`. + +Верхний лимит длины заголовка задачи **захардкожен** в +`src/webhooks/plane.py:362`: + +```python +if len(name) > 80: + errors.append("Title слишком длинный (максимум 80 символов)") +``` + +Лимит 80 — «гигиенический», а не структурный. Проверено, что **ниже по течению +ничего от значения 80 не зависит**: +- slug ветки режется независимо: `re.sub(...)[:30]` (`src/webhooks/plane.py:478`); +- БД `tasks.title TEXT` — без ограничения длины; +- Telegram-карточка использует `html.escape(title)` без обрезки; +- Plane хранит `name` самостоятельно. + +Следствие: вполне валидные осмысленные заголовки длиной 81–200 символов +отклоняются на входе конвейера без бизнес-причины. + +## 2. Цель (To-Be) +Вынести верхний лимит длины заголовка QG-0 в конфигурируемый параметр со +значением по умолчанию **200** (вместо текущего хардкода 80). Расширить лимит +безопасно, сохранив возможность регулировать его через окружение, как и +остальные `ORCH_*` настройки. + +## 3. Бизнес-ценность +- Меньше ложных отклонений валидных задач на входе конвейера (QoL для постановщика). +- Лимит становится операционно настраиваемым без правки кода и редеплоя + (изменение env-переменной). +- Изменение чисто аддитивное и обратносовместимое: дефолт 200 > прежних 80, поэтому + все заголовки, проходившие раньше, проходят и теперь. + +## 4. Объём (Scope) +### В объёме +- Новый параметр Settings `qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, дефолт 200). +- Замена хардкода `> 80` на `> settings.qg0_title_max` в `_qg0_errors`. +- Динамический текст ошибки с подстановкой актуального лимита. +- Graceful-поведение при невалидном/пустом значении env → дефолт 200, без падения процесса. +- Документация: `.env.example`, `.env.staging.example`, `CHANGELOG.md`, + при необходимости README-таблица конфигов / `CLAUDE.md`. +- Юнит-тесты на `_qg0_errors` с разными лимитами. + +### Вне объёма (Out of scope) +- Slug-логика `[:30]` (`src/webhooks/plane.py:478`) — самодостаточна, не трогать. +- Нижний лимит заголовка (`< 5`) и лимит description (`< 20`) — оставить как есть. +- Схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`. +- Soft-QG-0 на `work_item.created` (там только warning) — логика валидации общая + (`_qg0_errors`), отдельных изменений не требует и не вносит. + +## 5. Заинтересованные стороны +- Owner / постановщик задач (Слава) — снижение ложных отклонений. +- Агенты конвейера — поведение QG-0 при старте pipeline. + +## 6. Ограничения и риски (self-hosting) +- Правка касается работающего в проде инструмента (self-hosting). Прод-контейнер + `orchestrator` в рамках задачи **не рестартить**; обязательна страховка + `deploy-staging` (8501). +- Риск минимален: изменение обратносовместимо, изолировано в одной функции и одном + новом параметре config. + +## 7. Допущения +- Механизм чтения env — стандартный `pydantic_settings.BaseSettings` с + `env_prefix = "ORCH_"`, как у остальных параметров. +- «Невалидное/пустое значение → дефолт 200» — требование graceful-деградации: + процесс не должен падать на старте из-за мусора в `ORCH_QG0_TITLE_MAX` + (нюанс реализации pydantic-валидации передаётся архитектору, см. 02-trz §5). diff --git a/docs/work-items/ORCH-069/02-trz.md b/docs/work-items/ORCH-069/02-trz.md new file mode 100644 index 0000000..7ffb071 --- /dev/null +++ b/docs/work-items/ORCH-069/02-trz.md @@ -0,0 +1,95 @@ +# ТЗ — ORCH-069: QG-0 title-лимит → параметр ORCH_QG0_TITLE_MAX (дефолт 200) + +Work Item ID: ORCH-069 + +## 1. Задействованные модули `src/` +| Файл | Текущее состояние | Требуемое изменение | +|------|-------------------|---------------------| +| `src/config.py` | `Settings(BaseSettings)`, `env_prefix = "ORCH_"` (строки 4, 347-349) | Добавить поле `qg0_title_max: int = 200` с комментарием-описанием. | +| `src/webhooks/plane.py` | `_qg0_errors` (строки 357-367), хардкод `if len(name) > 80:` (строка 362); `from ..config import settings` уже импортирован (строка 11) | Заменить хардкод `> 80` на `> settings.qg0_title_max`; текст ошибки — динамический с подстановкой лимита. | + +Других модулей изменение не затрагивает. + +## 2. Изменение config.py +Добавить в класс `Settings` новое поле (рядом с другими `ORCH_*` группами, +рекомендуется отдельный блок с комментарием): + +```python +# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char +# cap was a hygiene limit, not structural (slug is cut to [:30] independently, +# DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default +# 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). +qg0_title_max: int = 200 +``` + +- Env-переменная: `ORCH_QG0_TITLE_MAX` (автоматически из `env_prefix = "ORCH_"`). +- Тип `int`, дефолт `200`. + +## 3. Изменение `_qg0_errors` (src/webhooks/plane.py) +Текущий блок (строки 362-363): +```python +if len(name) > 80: + errors.append("Title слишком длинный (максимум 80 символов)") +``` + +Требуемое: +```python +if len(name) > settings.qg0_title_max: + errors.append( + f"Title слишком длинный (максимум {settings.qg0_title_max} символов)" + ) +``` + +Требования: +- Лимит берётся из `settings.qg0_title_max` (динамически, на каждый вызов — чтобы + тесты могли подменять значение через мок/патч settings). +- Текст ошибки содержит актуальное число лимита (для AC-1/AC-2: текст упоминает + 200 / 120 соответственно). +- Нижний лимит заголовка `< 5` (строка 360-361) и проверка description `< 20` + (строка 364-365) — **не трогать**. +- Сигнатура `_qg0_errors(name, description) -> list` не меняется. + +## 4. Поведение границы (точная семантика) +- Условие fail — строго `len(name) > limit`. То есть `len == limit` → PASS, + `len == limit + 1` → FAIL. +- При дефолте: 200 символов → PASS, 201 → FAIL. +- При `ORCH_QG0_TITLE_MAX=120`: 120 → PASS, 121 → FAIL. + +## 5. Graceful-обработка невалидного значения (требование AC-3) +Требование: невалидное/отсутствующее `ORCH_QG0_TITLE_MAX` → используется дефолт 200, +процесс не падает. + +Нюанс для архитектора/разработчика: `pydantic_settings` по умолчанию при +непарсящемся в `int` значении env (например `ORCH_QG0_TITLE_MAX=abc` или пустая +строка) выбрасывает `ValidationError` на инстанцировании `Settings()` — +т.е. падение на старте процесса. Это противоречит требованию graceful. +Реализация должна обеспечить, что: +- отсутствие переменной → дефолт 200 (это стандартное поведение, ОК «из коробки»); +- пустая строка / нечисловое значение → дефолт 200 без исключения. + +Способ (на усмотрение архитектора, без предписания со стороны аналитика) — +например field-validator с `mode="before"`, который при невалидном входе +возвращает дефолт. Конкретный механизм фиксируется в ADR на стадии architecture. + +## 6. Изменения API +Нет. Эндпоинты не меняются. + +## 7. Изменения схемы БД +Нет. `tasks.title TEXT` остаётся без ограничения длины. + +## 8. Новые QG checks +Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. QG-0 — не зарегистрированный +stage-gate, а inline-валидация входа (`_qg0_errors`), её контракт сохраняется. + +## 9. Артефакты pipeline, которые должны быть созданы/обновлены +- `.env.example` — добавить `ORCH_QG0_TITLE_MAX=200` с комментарием. +- `.env.staging.example` — добавить `ORCH_QG0_TITLE_MAX` (дефолт/комментарий). +- `CHANGELOG.md` — запись об ORCH-069. +- README-таблица конфигов / `CLAUDE.md` — обновить при наличии релевантной таблицы + параметров (по требованию reviewer; документация = golden source). +- Юнит-тесты (`tests/`) — см. `04-test-plan.yaml`. + +## 10. Обратная совместимость +- Дефолт 200 > прежних 80 → все ранее проходившие заголовки проходят и теперь. +- Поведение при не заданном env идентично «как было», но с порогом 200 вместо 80. +- Изменение чисто аддитивное; откатов/миграций не требует. diff --git a/docs/work-items/ORCH-069/03-acceptance-criteria.md b/docs/work-items/ORCH-069/03-acceptance-criteria.md new file mode 100644 index 0000000..a76e91f --- /dev/null +++ b/docs/work-items/ORCH-069/03-acceptance-criteria.md @@ -0,0 +1,56 @@ +# Критерии приёмки — ORCH-069 + +Work Item ID: ORCH-069 + +Формат: каждый критерий имеет чёткое условие PASS/FAIL. + +## AC-1 — Дефолтный лимит 200, граница на 201 +**Дано:** env `ORCH_QG0_TITLE_MAX` не задан (используется дефолт 200), description валиден (≥ 20 символов). +**Тогда:** +- заголовок длиной 200 символов → `_qg0_errors` НЕ содержит ошибки про длину title (PASS); +- заголовок длиной 201 символ → `_qg0_errors` содержит ошибку про длину title, и текст ошибки упоминает «200». +**FAIL если:** на 200 появляется ошибка длины, либо на 201 ошибки нет, либо текст не упоминает 200. + +## AC-2 — Настраиваемый лимит 120, граница на 121 +**Дано:** `ORCH_QG0_TITLE_MAX=120` (через мок/патч settings в тесте), description валиден. +**Тогда:** +- заголовок 120 символов → нет ошибки длины title (PASS); +- заголовок 121 символ → есть ошибка длины title, текст упоминает «120». +**FAIL если:** граница срабатывает не на 121, либо текст ошибки упоминает не 120. + +## AC-3 — Graceful при невалидном/пустом значении +**Дано:** `ORCH_QG0_TITLE_MAX` пустой (`""`) или нечисловой (`"abc"`). +**Тогда:** +- инстанцирование `Settings()` / импорт приложения НЕ выбрасывает исключение (процесс не падает); +- эффективное значение лимита = дефолт 200 (поведение AC-1 сохраняется). +**FAIL если:** старт процесса падает с `ValidationError`, либо лимит != 200. + +## AC-4 — Нижние лимиты не сломаны +**Дано:** любое валидное значение `ORCH_QG0_TITLE_MAX`. +**Тогда:** +- заголовок длиной < 5 символов → `_qg0_errors` содержит ошибку «Title слишком короткий»; +- description длиной < 20 символов → `_qg0_errors` содержит ошибку «Description слишком короткий». +**FAIL если:** нижний лимит title или лимит description перестал срабатывать. + +## AC-5 — Юнит-тесты зелёные +**Дано:** реализованные юнит-тесты на `_qg0_errors` с разными значениями лимита (мок settings). +**Тогда:** `pytest tests/ -q` проходит полностью (зелёный), включая новые тесты ORCH-069 и существующий набор. +**FAIL если:** хотя бы один тест падает. + +## AC-6 — Документация обновлена в том же PR +**Дано:** PR с изменениями кода. +**Тогда в том же PR:** +- `.env.example` содержит `ORCH_QG0_TITLE_MAX` с дефолтом и комментарием; +- `.env.staging.example` содержит `ORCH_QG0_TITLE_MAX`; +- `CHANGELOG.md` содержит запись об ORCH-069; +- при наличии релевантной таблицы конфигов в README / `CLAUDE.md` — она обновлена. +**FAIL если:** какой-либо из обязательных файлов документации не обновлён (reviewer → REQUEST_CHANGES). + +## AC-7 — Обратная совместимость +**Дано:** env не задан. +**Тогда:** любой заголовок, который проходил QG-0 при прежнем лимите 80 (len ≤ 80), проходит и теперь (len ≤ 200). +**FAIL если:** ранее валидный заголовок отклоняется. + +## AC-8 — Изоляция изменений +**Тогда:** не изменены slug-логика (`[:30]`), схема БД, реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, контракты `handle_*`, soft-QG-0 поведение (warning на `work_item.created`). +**FAIL если:** затронут любой из перечисленных вне-объёмных элементов. diff --git a/docs/work-items/ORCH-069/04-test-plan.yaml b/docs/work-items/ORCH-069/04-test-plan.yaml new file mode 100644 index 0000000..b99bcb0 --- /dev/null +++ b/docs/work-items/ORCH-069/04-test-plan.yaml @@ -0,0 +1,112 @@ +work_item: ORCH-069 +description: > + Юнит-тесты для конфигурируемого верхнего лимита длины заголовка QG-0 + (_qg0_errors) через параметр settings.qg0_title_max (env ORCH_QG0_TITLE_MAX, + дефолт 200). Тесты патчат settings.qg0_title_max (monkeypatch на объекте + src.config.settings, который импортирован в src.webhooks.plane) и проверяют + границы и тексты ошибок. Файл тестов: tests/test_qg0_title_limit.py. + +tests: + - id: TC-01 + type: unit + description: "Дефолтный лимит 200: заголовок ровно 200 символов -> нет ошибки длины title (PASS на границе)." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200 (дефолт); name='x'*200; description валиден (>=20 символов)." + assert: "В списке _qg0_errors нет элемента про длину title." + covers: [AC-1] + expected: PASS + + - id: TC-02 + type: unit + description: "Дефолтный лимит 200: заголовок 201 символ -> ошибка длины title, текст упоминает '200'." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='x'*201; description валиден." + assert: "В _qg0_errors есть ошибка длины title и её текст содержит подстроку '200'." + covers: [AC-1] + expected: PASS + + - id: TC-03 + type: unit + description: "Настраиваемый лимит 120: заголовок 120 символов -> нет ошибки длины title." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch settings.qg0_title_max=120; name='x'*120; description валиден." + assert: "Нет ошибки длины title." + covers: [AC-2] + expected: PASS + + - id: TC-04 + type: unit + description: "Настраиваемый лимит 120: заголовок 121 символ -> ошибка длины title, текст упоминает '120'." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch settings.qg0_title_max=120; name='x'*121; description валиден." + assert: "Есть ошибка длины title и её текст содержит подстроку '120' (и НЕ '80')." + covers: [AC-2] + expected: PASS + + - id: TC-05 + type: unit + description: "Graceful: невалидное (нечисловое) значение env ORCH_QG0_TITLE_MAX не роняет инстанцирование Settings и даёт дефолт 200." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','abc'); создать новый экземпляр Settings()." + assert: "Settings() не выбрасывает исключение; settings.qg0_title_max == 200." + covers: [AC-3] + expected: PASS + + - id: TC-06 + type: unit + description: "Graceful: пустая строка env ORCH_QG0_TITLE_MAX -> дефолт 200, без исключения." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX',''); создать новый экземпляр Settings()." + assert: "Settings() не падает; settings.qg0_title_max == 200." + covers: [AC-3] + expected: PASS + + - id: TC-07 + type: unit + description: "Корректное числовое env -> применяется заданное значение (sanity положительного пути)." + module: tests/test_qg0_title_limit.py + setup: "monkeypatch.setenv('ORCH_QG0_TITLE_MAX','150'); создать новый экземпляр Settings()." + assert: "settings.qg0_title_max == 150." + covers: [AC-2, AC-3] + expected: PASS + + - id: TC-08 + type: unit + description: "Нижний лимит title не сломан: заголовок < 5 символов -> ошибка 'Title слишком короткий' при любом верхнем лимите." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='abc' (3 символа); description валиден." + assert: "В _qg0_errors есть ошибка короткого title." + covers: [AC-4] + expected: PASS + + - id: TC-09 + type: unit + description: "Лимит description не сломан: description < 20 символов -> ошибка 'Description слишком короткий'." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name валиден (>=5, <=200); description='short'." + assert: "В _qg0_errors есть ошибка короткого description." + covers: [AC-4] + expected: PASS + + - id: TC-10 + type: unit + description: "Обратная совместимость: заголовок длиной 81-200 (ранее отклонялся лимитом 80) теперь проходит при дефолте." + module: tests/test_qg0_title_limit.py + setup: "settings.qg0_title_max=200; name='x'*100; description валиден." + assert: "Нет ошибки длины title (раньше при лимите 80 была бы)." + covers: [AC-7] + expected: PASS + + - id: TC-11 + type: unit + description: "Полный набор тестов зелёный (регрессия не внесена)." + module: tests/ + command: "pytest tests/ -q" + assert: "Все тесты проходят." + covers: [AC-5] + expected: PASS + +notes: + - "settings импортирован в src.webhooks.plane как 'from ..config import settings', _qg0_errors читает settings.qg0_title_max динамически -> monkeypatch на src.config.settings.qg0_title_max (или импортируемом объекте) меняет поведение в рамках теста." + - "Для TC-05/06/07 нужен СВЕЖИЙ экземпляр Settings(): глобальный src.config.settings создаётся один раз на импорт, поэтому env-тесты инстанцируют Settings() локально, а не полагаются на готовый синглтон." + - "Тесты не требуют сети, БД, агентов или FastAPI TestClient — чистая проверка leaf-функции _qg0_errors и парсинга Settings." diff --git a/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md b/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md new file mode 100644 index 0000000..49e1fba --- /dev/null +++ b/docs/work-items/ORCH-069/06-adr/ADR-001-configurable-qg0-title-limit.md @@ -0,0 +1,143 @@ +# ADR-001: Конфигурируемый QG-0 title-лимит с graceful-деградацией env + +## Статус +Accepted + +## Контекст +QG-0 — inline-валидация входа конвейера (`_qg0_errors` в `src/webhooks/plane.py`), +вызывается из `start_pipeline` (hard-блок) и из `handle_work_item_created` +(soft-warning). Верхний лимит длины заголовка захардкожен: `if len(name) > 80`. + +BRD/ТЗ (ORCH-069) установили, что лимит 80 — гигиенический, а не структурный: +ниже по течению от него ничего не зависит (slug режется независимо `[:30]`, +`tasks.title TEXT` без ограничения, Telegram/Plane хранят/экранируют сами). +Валидные заголовки 81–200 символов отклоняются на входе без бизнес-причины. + +Требуется: +1. Вынести лимит в конфигурируемый параметр `ORCH_QG0_TITLE_MAX`, дефолт 200. +2. **Graceful-деградация** (AC-3): пустое/нечисловое значение env → дефолт 200 + **без падения процесса**. Это и есть единственное нетривиальное архитектурное + решение задачи: `pydantic_settings` v2 по умолчанию при непарсящемся в `int` + значении env бросает `ValidationError` на инстанцировании `Settings()` — + т.е. краш на старте контейнера (`settings = Settings()` на module-import, + `src/config.py:352`). Для self-hosting это означало бы падение прод-инструмента + из-за опечатки в env — недопустимо. + +Стек подтверждён: `pydantic==2.13.4`, `pydantic-settings==2.5.0` (v2 API). + +## Решение + +### Р-1. Новый параметр Settings +В `src/config.py`, в класс `Settings`, добавить поле (отдельный блок с +комментарием, рядом с прочими `ORCH_*`): + +```python +# ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). +# 80-char cap was a hygiene limit, not structural. Env ORCH_QG0_TITLE_MAX; +# default 200 (was hardcoded 80). Invalid/empty -> default (graceful, no crash). +qg0_title_max: int = 200 +``` +Env-имя выводится автоматически из `env_prefix = "ORCH_"` → `ORCH_QG0_TITLE_MAX`. + +### Р-2. Механизм graceful-деградации — `field_validator(mode="before")` +Выбран **pydantic v2 `field_validator` с `mode="before"`** как +минимально-инвазивный, локальный для одного поля механизм. Валидатор перехватывает +сырое значение env ДО стандартного `int`-парсинга и при невалидном/пустом входе +возвращает дефолт `200`, гася `ValidationError`: + +```python +from pydantic import field_validator + +@field_validator("qg0_title_max", mode="before") +@classmethod +def _qg0_title_max_default(cls, v): + # Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, + # process must not crash on startup. Never raises. + try: + if v is None or (isinstance(v, str) and v.strip() == ""): + return 200 + return int(v) + except (TypeError, ValueError): + return 200 +``` + +Семантика: +- переменная не задана → pydantic не вызывает validator с env, берётся дефолт поля + `200` (стандартное поведение «из коробки»); +- `""`, `"abc"`, мусор → validator возвращает `200`, исключения нет; +- `"120"` → `int("120") == 120`. + +**Почему именно так (рассмотренные альтернативы):** +- *`Optional[int] + None-fallback на месте чтения`* — отвергнуто: размазывает + дефолт по call-site'ам, легко забыть, тип поля перестаёт быть «честным `int`». +- *try/except вокруг `Settings()` на module-level* — отвергнуто: глушит ВСЕ + ошибки конфигурации (маскирует реальные проблемы других полей), слишком грубо. +- *кастомный тип / `Annotated`-валидатор* — избыточно для одного поля. +- `field_validator(mode="before")` локален, не трогает остальные поля, не меняет + публичный тип `int`, тестируется напрямую через `Settings(qg0_title_max=...)` и + env-патч. Контракт «never-raise» консистентен с общим стилем кодовой базы + (`_qg0_errors`, парсеры — defensive). + +### Р-3. Использование лимита в `_qg0_errors` +Хардкод `> 80` → динамическое чтение `settings.qg0_title_max` **на каждый вызов** +(чтобы тест мог патчить `settings`), текст ошибки — f-string с актуальным числом: + +```python +if len(name) > settings.qg0_title_max: + errors.append( + f"Title слишком длинный (максимум {settings.qg0_title_max} символов)" + ) +``` +`settings` уже импортирован в `plane.py`. Сигнатура `_qg0_errors(name, description) +-> list` не меняется. Нижние лимиты (`< 5` title, `< 20` description) — без правок. + +Граница (ТЗ §4): fail строго при `len(name) > limit` → `len == limit` PASS, +`limit + 1` FAIL. + +### Р-4. Что НЕ меняется (инварианты) +- `STAGE_TRANSITIONS`, `QG_CHECKS` — QG-0 не зарегистрированный stage-gate, а + inline-валидация; реестры не трогаются. +- Схема БД (`tasks.title TEXT`), API, контракты `handle_*`, slug-логика `[:30]`, + soft-QG-0 поведение (общая функция `_qg0_errors`, отдельной правки не требует). +- Топология/инфраструктура (`07-infra-requirements.md` — **N/A**) и схема данных + (`08-data-requirements.md` — **N/A**) не затрагиваются. + +## Последствия + +### Плюсы +- Лимит операционно настраивается через env без правки кода и редеплоя кода. +- Чисто аддитивно и обратносовместимо: дефолт 200 > прежних 80 → все ранее + проходившие заголовки проходят (AC-7). +- Опечатка в `ORCH_QG0_TITLE_MAX` не роняет прод-процесс (критично для + self-hosting): graceful-fallback на 200. +- Изменение изолировано в одной функции + одном поле config + одном валидаторе. + +### Минусы / ограничения +- Невалидное env «тихо» проглатывается → оператор не сразу заметит опечатку + (лимит молча станет 200). Принято как осознанный trade-off: устойчивость + процесса важнее громкости (consistency с требованием AC-3). Рекомендация: + при желании усилить наблюдаемость — `logger.warning` в validator; **не вводим** + по умолчанию, т.к. на этапе валидации settings логгер может быть не сконфигурён, + и это вне объёма ORCH-069 (можно отдельной QoL-задачей). +- Дефолт 200 — тоже эвристика; структурного верхнего предела по-прежнему нет + (его и не требуется — БД/slug/UI к длине устойчивы). + +### Влияние на self-hosting +Прод-контейнер `orchestrator` **не рестартить** в рамках задачи. Изменение +прокатывается штатно через обязательный `deploy-staging`-гейт (8501) перед +прод-деплоем. Риск отказа на старте после деплоя снят самим механизмом Р-2 +(graceful), что дополнительно снижает self-hosting-риск. + +### Тестируемость (вход для стадий development/testing) +- `_qg0_errors`: патч `settings.qg0_title_max` → проверка границ 200/201 (AC-1), + 120/121 (AC-2), нижних лимитов (AC-4). +- validator: `Settings(qg0_title_max="abc")` / `=""` / env-патч → значение 200, + без исключения (AC-3). + +## Ссылки +- BRD: `docs/work-items/ORCH-069/01-brd.md` +- ТЗ: `docs/work-items/ORCH-069/02-trz.md` +- Acceptance: `docs/work-items/ORCH-069/03-acceptance-criteria.md` +- Тех-риски: `docs/work-items/ORCH-069/10-tech-risks.md` + + diff --git a/docs/work-items/ORCH-069/10-tech-risks.md b/docs/work-items/ORCH-069/10-tech-risks.md new file mode 100644 index 0000000..b6bde09 --- /dev/null +++ b/docs/work-items/ORCH-069/10-tech-risks.md @@ -0,0 +1,21 @@ +# Технические риски — ORCH-069 + +Work Item ID: ORCH-069 +Уровень общего риска: **низкий** (аддитивное, обратносовместимое, изолированное изменение). + +| # | Риск | Вероятность | Влияние | Митигация | +|---|------|-------------|---------|-----------| +| R-1 | `ValidationError` на старте при мусоре в `ORCH_QG0_TITLE_MAX` → краш прод-процесса (self-hosting) | Средняя (опечатка в env) | Высокое (падение инструмента всех проектов) | `field_validator(mode="before")` гасит невалидный вход → дефолт 200 (ADR Р-2, AC-3). never-raise. | +| R-2 | Чтение лимита один раз на module-import вместо per-call → тесты не смогут патчить settings | Низкая | Среднее (нетестируемость AC-2) | `_qg0_errors` читает `settings.qg0_title_max` динамически на каждый вызов (ADR Р-3). | +| R-3 | Off-by-one на границе (`>=` вместо `>`) | Низкая | Низкое (1 символ) | Явная семантика `len > limit` зафиксирована (ТЗ §4, AC-1/AC-2); тесты на 200/201, 120/121. | +| R-4 | Регресс нижних лимитов (`< 5` title, `< 20` description) при правке функции | Низкая | Среднее | Трогать только верхний лимит; AC-4 покрывает нижние; диф минимален. | +| R-5 | Тихое проглатывание невалидного env → оператор не заметит опечатку | Средняя | Низкое (лимит молча = 200, конвейер работает) | Осознанный trade-off (ADR «Минусы»): устойчивость > громкость. Опц. `logger.warning` — вне объёма. | +| R-6 | Случайное затрагивание вне-объёмных элементов (slug `[:30]`, БД, реестры, `handle_*`, soft-QG-0) | Низкая | Среднее | AC-8 — изоляция; reviewer проверяет диф; ADR Р-4 фиксирует инварианты. | +| R-7 | Документация не обновлена в том же PR (`.env.example`, `.env.staging.example`, `CHANGELOG.md`) | Средняя | Среднее (reviewer REQUEST_CHANGES) | AC-6 чек-лист; документация = golden source (правило 2 CLAUDE.md). | + +## Не-риски (явно) +- Схема БД — не меняется (`tasks.title TEXT` без ограничения). +- API/эндпоинты — не меняются. +- Топология/контейнеры/порты — не меняются. +- Откат/миграция — не требуется (дефолт 200 > 80, чисто аддитивно). + diff --git a/docs/work-items/ORCH-069/12-review.md b/docs/work-items/ORCH-069/12-review.md new file mode 100644 index 0000000..ecd719b --- /dev/null +++ b/docs/work-items/ORCH-069/12-review.md @@ -0,0 +1,68 @@ +--- +type: review +work_item_id: ORCH-069 +verdict: APPROVED +version: 3 +--- + +# Review ORCH-069 + +## Summary +Реализация конфигурируемого QG-0 title-лимита `ORCH_QG0_TITLE_MAX` (дефолт 200) +выполнена **дословно по ТЗ/ADR** и качественно. Поле `Settings.qg0_title_max`, +graceful `field_validator(mode="before")` (never-raise → дефолт 200), динамическое +чтение `settings.qg0_title_max` в `_qg0_errors` с f-string-текстом ошибки. Код +изолирован (затронуты только `src/config.py` и `src/webhooks/plane.py`), инварианты +не нарушены, нижние лимиты сохранены. Свежий полный прогон на текущем состоянии +ветки: `pytest tests/ -q` → **863 passed** (включая 10 новых тестов ORCH-069, +файл `tests/test_qg0_title_limit.py`, все зелёные). Документация обновлена в том же +PR полностью. Блокирующих и must-fix findings нет → **APPROVED**. + +## Соответствие ТЗ / ADR +- `src/config.py` — поле `qg0_title_max: int = 200` + валидатор `_qg0_title_max_default` + (`mode="before"`, try/except → 200 при `None`/пустой/нечисловой): 1:1 с ADR Р-1/Р-2 + и ТЗ §2/§5. ✓ +- `src/webhooks/plane.py` — хардкод `> 80` заменён на `> settings.qg0_title_max`, + текст ошибки динамический (f-string с актуальным числом); сигнатура `_qg0_errors`, + нижний лимит title `< 5`, проверка description `< 20` не тронуты: ADR Р-3, ТЗ §3/§4. ✓ +- Граница строгая (`len == limit` PASS, `limit+1` FAIL) — подтверждена tc01–tc04. ✓ +- Инварианты (ADR Р-4 / AC-8): `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, slug `[:30]`, + soft-QG-0, API — НЕ изменены (diff `src/` = только 2 файла). ✓ + +## Acceptance criteria +- AC-1 (дефолт 200, граница 201, текст упоминает 200) — tc01/tc02 ✓ +- AC-2 (лимит 120, граница 121, текст 120 не 80) — tc03/tc04 ✓ +- AC-3 (graceful пустое/`abc` → 200 без краха) — tc05/tc06 + позитив tc07 + валидатор ✓ +- AC-4 (нижние лимиты title<5 / desc<20) — tc08/tc09 ✓ +- AC-5 (pytest зелёный) — 863 passed ✓ +- AC-6 (документация в том же PR) — выполнен полностью ✓ +- AC-7 (обратная совместимость, ≤80 проходит при 200) — tc10 ✓ +- AC-8 (изоляция изменений) — ✓ + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет) + +### P3 — Nice-to-have (не блокирует) +- В конце `06-adr/ADR-001-configurable-qg0-title-limit.md` присутствуют артефактные + хвостовые теги (``, ``). Косметика в артефакте стадии architecture; + на корректность кода/контракта не влияет. Править артефакт чужой стадии в рамках + ревью не уполномочен — отмечено для будущей чистки. + +## Документация +- `.env.example` — добавлен `ORCH_QG0_TITLE_MAX=200` с комментарием. ✓ +- `.env.staging.example` — добавлен `ORCH_QG0_TITLE_MAX=200`. ✓ +- `CHANGELOG.md` — подробная запись об ORCH-069 (раздел Added). ✓ +- `README.md` — таблица env-конфигов дополнена строкой `ORCH_QG0_TITLE_MAX`. ✓ +- ADR `06-adr/ADR-001-configurable-qg0-title-limit.md` — присутствует, согласован + с кодом. ✓ +- `docs/architecture/README.md` / `CLAUDE.md` — обновления не требуют (QG-0 — inline + soft/hard-валидация входа, не зарегистрированный stage-gate; API/стадии/QG-реестр + не менялись). ОК. diff --git a/docs/work-items/ORCH-069/13-test-report.md b/docs/work-items/ORCH-069/13-test-report.md new file mode 100644 index 0000000..f440e91 --- /dev/null +++ b/docs/work-items/ORCH-069/13-test-report.md @@ -0,0 +1,98 @@ +--- +type: test-report +work_item_id: ORCH-069 +result: PASS +--- + +# Test Report — ORCH-069 + +QG-0 title-лимит → параметр `ORCH_QG0_TITLE_MAX` (дефолт 200) + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=auto) +- Ветка: `feature/ORCH-069-qg-0-title-orch-qg0-title-max-` +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-069-qg-0-title-orch-qg0-title-max-` +- Prod-health (8500): `{"status":"ok","service":"orchestrator"}` — не трогался (self-hosting safety) +- Дата: 2026-06-08 + +## Предусловия +- Review-вердикт `12-review.md`: **APPROVED** (version 3) ✓ +- Изменения изолированы: `src/config.py`, `src/webhooks/plane.py` (+ тесты, + документация) + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | Покрывает | Результат | +|-------|----------|-----------|-----------| +| TC-01 | Дефолт 200: title=200 → нет ошибки длины (граница PASS) | AC-1 | PASS | +| TC-02 | Дефолт 200: title=201 → ошибка длины, текст упоминает «200» | AC-1 | PASS | +| TC-03 | Лимит 120: title=120 → нет ошибки длины | AC-2 | PASS | +| TC-04 | Лимит 120: title=121 → ошибка, текст «120» (не «80») | AC-2 | PASS | +| TC-05 | Graceful: env `abc` → дефолт 200, без краха `Settings()` | AC-3 | PASS | +| TC-06 | Graceful: пустой env `""` → дефолт 200, без исключения | AC-3 | PASS | +| TC-07 | Валидный env `150` → применяется 150 (позитивный путь) | AC-2, AC-3 | PASS | +| TC-08 | Нижний лимит title < 5 не сломан | AC-4 | PASS | +| TC-09 | Лимит description < 20 не сломан | AC-4 | PASS | +| TC-10 | Обратная совместимость: title 81–200 проходит при дефолте | AC-7 | PASS | +| TC-11 | Полный набор тестов зелёный (нет регрессии) | AC-5 | PASS | + +## Сопоставление с критериями приёмки (03-acceptance-criteria.md) + +| AC | Критерий | Статус | +|----|----------|--------| +| AC-1 | Дефолт 200, граница на 201, текст упоминает 200 | PASS (TC-01/02) | +| AC-2 | Настраиваемый лимит 120, граница 121, текст 120 | PASS (TC-03/04/07) | +| AC-3 | Graceful при пустом/нечисловом значении → 200 | PASS (TC-05/06) | +| AC-4 | Нижние лимиты title<5 / description<20 не сломаны | PASS (TC-08/09) | +| AC-5 | Юнит-тесты зелёные (весь набор) | PASS (863 passed) | +| AC-6 | Документация в том же PR (.env.example, .env.staging.example, CHANGELOG, README) | PASS (подтверждено review) | +| AC-7 | Обратная совместимость (≤80 проходит при 200) | PASS (TC-10) | +| AC-8 | Изоляция: slug `[:30]`, БД, STAGE_TRANSITIONS/QG_CHECKS, handle_* не тронуты | PASS (diff = 2 файла src/) | + +## Smoke test API (prod 8500, read-only) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK +- `GET /status` → отдаёт активные задачи (ORCH-069 в стадии `testing`) — OK +- `GET /queue` → `counts: queued=0 running=1 done=459 failed=4 cancelled=1`; breaker `closed`, preflight ok — OK + +## Целевой прогон ORCH-069 (tests/test_qg0_title_limit.py) +``` +collected 10 items + +tests/test_qg0_title_limit.py::test_tc01_default_limit_200_boundary_pass PASSED +tests/test_qg0_title_limit.py::test_tc02_default_limit_200_boundary_fail PASSED +tests/test_qg0_title_limit.py::test_tc03_custom_limit_120_boundary_pass PASSED +tests/test_qg0_title_limit.py::test_tc04_custom_limit_120_boundary_fail PASSED +tests/test_qg0_title_limit.py::test_tc05_graceful_non_numeric_env PASSED +tests/test_qg0_title_limit.py::test_tc06_graceful_empty_env PASSED +tests/test_qg0_title_limit.py::test_tc07_valid_numeric_env PASSED +tests/test_qg0_title_limit.py::test_tc08_short_title_still_errors PASSED +tests/test_qg0_title_limit.py::test_tc09_short_description_still_errors PASSED +tests/test_qg0_title_limit.py::test_tc10_backward_compat_titles_81_to_200 PASSED + +======================== 10 passed, 1 warning in 0.31s ========================= +``` + +## Полный прогон (pytest tests/ -q) +``` +........................................................................ [ 8%] +........................................................................ [ 16%] +........................................................................ [ 25%] +........................................................................ [ 33%] +........................................................................ [ 41%] +........................................................................ [ 50%] +........................................................................ [ 58%] +........................................................................ [ 66%] +........................................................................ [ 75%] +........................................................................ [ 83%] +........................................................................ [ 91%] +....................................................................... [100%] +863 passed, 1 warning in 21.49s +``` + +(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, существующий +class-based config; к ORCH-069 не относится, не является ошибкой.) + +## Итог +**PASS** — все 11 TC из тест-плана пройдены, все 8 критериев приёмки выполнены, +полный регресс зелёный (863 passed), smoke-тесты API OK. Регрессии не внесены. +Задача готова к переходу на стадию `deploy-staging`. diff --git a/docs/work-items/ORCH-069/14-deploy-log.md b/docs/work-items/ORCH-069/14-deploy-log.md new file mode 100644 index 0000000..2ca83f2 --- /dev/null +++ b/docs/work-items/ORCH-069/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-069 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-069/17-security-report.md b/docs/work-items/ORCH-069/17-security-report.md new file mode 100644 index 0000000..243c333 --- /dev/null +++ b/docs/work-items/ORCH-069/17-security-report.md @@ -0,0 +1,25 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 4 +deps_audit_degraded: false +--- +# Security Report — ORCH-069 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 diff --git a/src/config.py b/src/config.py index 2866265..48fd249 100644 --- a/src/config.py +++ b/src/config.py @@ -1,3 +1,4 @@ +from pydantic import field_validator from pydantic_settings import BaseSettings @@ -422,6 +423,24 @@ class Settings(BaseSettings): tracker_live_status_ttl_s: int = 60 tracker_live_status_timeout_s: int = 3 + # ORCH-069: QG-0 upper title-length limit (entry gate _qg0_errors). The 80-char + # cap was a hygiene limit, not structural (slug is cut to [:30] independently, + # DB title TEXT is unbounded). Configurable via env ORCH_QG0_TITLE_MAX; default + # 200 (was hardcoded 80). Invalid/empty value -> default (graceful, no crash). + qg0_title_max: int = 200 + + @field_validator("qg0_title_max", mode="before") + @classmethod + def _qg0_title_max_default(cls, v): + # Graceful (ORCH-069 AC-3): empty / non-numeric env -> default 200, the + # process must not crash on startup. Never raises (self-hosting safety). + try: + if v is None or (isinstance(v, str) and v.strip() == ""): + return 200 + return int(v) + except (TypeError, ValueError): + return 200 + class Config: env_prefix = "ORCH_" env_file = ".env" diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 875f54a..4bdaf0c 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -416,8 +416,11 @@ def _qg0_errors(name: str, description: str) -> list: errors = [] if not name or len(name) < 5: errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 5 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") - if len(name) > 80: - errors.append("Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 (\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c 80 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") + if len(name) > settings.qg0_title_max: + errors.append( + f"Title \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u0434\u043b\u0438\u043d\u043d\u044b\u0439 " + f"(\u043c\u0430\u043a\u0441\u0438\u043c\u0443\u043c {settings.qg0_title_max} \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)" + ) if not description or len(description.strip()) < 20: errors.append("Description \u0441\u043b\u0438\u0448\u043a\u043e\u043c \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 (\u043d\u0443\u0436\u043d\u043e >= 20 \u0441\u0438\u043c\u0432\u043e\u043b\u043e\u0432)") diff --git a/tests/test_qg0_title_limit.py b/tests/test_qg0_title_limit.py new file mode 100644 index 0000000..44b2962 --- /dev/null +++ b/tests/test_qg0_title_limit.py @@ -0,0 +1,117 @@ +"""ORCH-069: unit tests for the configurable QG-0 title-length limit. + +Covers `_qg0_errors` (src/webhooks/plane.py) reading the upper title limit +dynamically from `settings.qg0_title_max` (env `ORCH_QG0_TITLE_MAX`, default 200), +plus the graceful env-degradation field-validator on `Settings`. + +The tests patch `src.config.settings.qg0_title_max` (the same object imported into +`src.webhooks.plane`) and assert boundary behaviour and error texts. For env-driven +cases a FRESH `Settings()` instance is created locally, since the module-level +singleton is built once on import. +""" + +import re + +import pytest + +from src.config import Settings, settings +from src.webhooks.plane import _qg0_errors + +VALID_DESCRIPTION = "x" * 30 # >= 20 chars, always passes the description check + + +def _title_length_error(errors): + """Return the title length-limit error string, or None if absent. + + The short-title error ('нужно >= 5') and the description error are excluded; + only the 'too long' title error is matched (it contains 'максимум'). + """ + for e in errors: + if "Title" in e and "максимум" in e: + return e + return None + + +# --- AC-1: default limit 200, boundary at 201 ------------------------------ + +def test_tc01_default_limit_200_boundary_pass(monkeypatch): + """TC-01: title of exactly 200 chars -> no title length error (PASS).""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 200, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc02_default_limit_200_boundary_fail(monkeypatch): + """TC-02: title of 201 chars -> length error mentioning '200'.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 201, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "200" in err + + +# --- AC-2: configurable limit 120, boundary at 121 ------------------------- + +def test_tc03_custom_limit_120_boundary_pass(monkeypatch): + """TC-03: with limit 120, a 120-char title passes.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 120, VALID_DESCRIPTION) + assert _title_length_error(errors) is None + + +def test_tc04_custom_limit_120_boundary_fail(monkeypatch): + """TC-04: with limit 120, a 121-char title fails; text mentions 120 not 80.""" + monkeypatch.setattr(settings, "qg0_title_max", 120) + errors = _qg0_errors("x" * 121, VALID_DESCRIPTION) + err = _title_length_error(errors) + assert err is not None + assert "120" in err + assert "80" not in err + + +# --- AC-3: graceful handling of invalid/empty env -------------------------- + +def test_tc05_graceful_non_numeric_env(monkeypatch): + """TC-05: non-numeric env -> Settings() does not raise, limit == 200.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "abc") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc06_graceful_empty_env(monkeypatch): + """TC-06: empty-string env -> default 200, no exception.""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "") + s = Settings() + assert s.qg0_title_max == 200 + + +def test_tc07_valid_numeric_env(monkeypatch): + """TC-07: valid numeric env -> the given value is applied (positive path).""" + monkeypatch.setenv("ORCH_QG0_TITLE_MAX", "150") + s = Settings() + assert s.qg0_title_max == 150 + + +# --- AC-4: lower limits unchanged ------------------------------------------ + +def test_tc08_short_title_still_errors(monkeypatch): + """TC-08: title < 5 chars still raises the short-title error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("abc", VALID_DESCRIPTION) + assert any("Title" in e and "нужно >= 5" in e for e in errors) + + +def test_tc09_short_description_still_errors(monkeypatch): + """TC-09: description < 20 chars still raises the short-description error.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("Valid title", "short") + assert any("Description" in e for e in errors) + + +# --- AC-7: backward compatibility ------------------------------------------ + +def test_tc10_backward_compat_titles_81_to_200(monkeypatch): + """TC-10: a title previously rejected by the 80-char cap now passes at 200.""" + monkeypatch.setattr(settings, "qg0_title_max", 200) + errors = _qg0_errors("x" * 100, VALID_DESCRIPTION) + assert _title_length_error(errors) is None