From e786428a1d7ac7a7d7c376511f7279a8b509a94d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:06:19 +0400 Subject: [PATCH 01/12] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=20C=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=20=D1=85=D0=B0=D0=B1=D0=B0=20hub-backend-fixes=20(?= =?UTF-8?q?#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/hub-backend-fixes/.openspec.yaml | 2 + openspec/changes/hub-backend-fixes/design.md | 73 +++++++++++++++++++ .../changes/hub-backend-fixes/proposal.md | 45 ++++++++++++ .../hub-backend-fixes/specs/commands/spec.md | 32 ++++++++ .../specs/data-model/spec.md | 56 ++++++++++++++ .../hub-backend-fixes/specs/hub-api/spec.md | 51 +++++++++++++ 6 files changed, 259 insertions(+) create mode 100644 openspec/changes/hub-backend-fixes/.openspec.yaml create mode 100644 openspec/changes/hub-backend-fixes/design.md create mode 100644 openspec/changes/hub-backend-fixes/proposal.md create mode 100644 openspec/changes/hub-backend-fixes/specs/commands/spec.md create mode 100644 openspec/changes/hub-backend-fixes/specs/data-model/spec.md create mode 100644 openspec/changes/hub-backend-fixes/specs/hub-api/spec.md diff --git a/openspec/changes/hub-backend-fixes/.openspec.yaml b/openspec/changes/hub-backend-fixes/.openspec.yaml new file mode 100644 index 0000000..d58ba02 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-26 diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md new file mode 100644 index 0000000..1e76670 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/design.md @@ -0,0 +1,73 @@ +## Context + +Пакет C сверки follow-ups 25.10.2026 (sdlc-hub#129). Все правки — в Python-коде хаба, без смены схемы базы и контракта +с агентами. Владелец пропустил вопросы brainstorming («не сталкивался и не хочу»): решения ниже приняты агентом и +продублированы в «Decisions made without discussion». + +## Decisions + +1. **Нормализация параметров — один шаг в `validate`.** После всех проверок `validate` кладёт в пакет параметры, + приведённые функцией `normalize(ctype, params) -> dict` (`modules/jira/commands.py`). Проверки на пустоту + (`.strip()`) остаются как есть. Обрезаются строковые параметры всех типов, кроме текстовых `summary`, + `description`, `body`, `goal`. Это ключи, `temp_key`, `sprint`, `rename_to`, `move_open_to`, `link_type`, + `assignee`, `priority`, даты, `marker`, `attachment_id`, `file`, `filename`, `issue_type`, `target` (строка или + элементы списка). Элементы списков `labels`, `labels_add`, `labels_remove`, `keys` тоже обрезаются. Числа, + логические значения и `null` не меняются. Нормализация возвращает новый словарь и не меняет вход. +2. **`filename` — NFC.** `normalize` приводит `filename` у `ensure_attachment` к `unicodedata.normalize("NFC", …)`. + MCP подставляет имя по умолчанию из `Path(path).name` и тоже приводит его к NFC; на macOS имена из пути приходят в + NFD. Поиск вложения по имени в MCP-инструменте `attachment` сравнивает имена в NFC с обеих сторон. +3. **Проверки `hooks.check_batch` идут по нормализованным параметрам.** `validate` вызывается раньше, поэтому + `filename` с пробелами по краям проверяется уже обрезанным. +4. **ASCII-цифры.** В `core/http.py` (рядом с `int_group`) появляется функция `is_digits(s) -> bool`: `s` — непустая строка только из + `0-9` (`s.isascii() and s.isdigit()`). Модуль jira использует её вместо `str.isdigit()` везде, где за проверкой + идёт `int()` или где строка — идентификатор: `hooks.py` (`attachment_id`), `mirror.py` (`check_attachments`), + `attachments.py` (сортировка), `fetch.py` (версия агента), `mcp.py` (ссылка `#`). В разборе дат `history.py` + и `mcp.py` значение уже прошло форматную проверку, но для единообразия там тоже `is_digits`. +5. **Форма элементов состояния.** `mirror.check_issues(issues)` вызывается до `check_comments` и + `check_attachments`. Она требует: `issues` — список (иначе сейчас 500 на итерации), каждый элемент — объект с непустым строковым `key`. Иначе + `ValueError` с номером элемента. Обработчик `POST /agent/state` уже превращает `ValueError` в 422 `bad_state`, + как для комментариев. +6. **`POST /wiki/sync`.** Если `project` в теле задан и это не строка, ответ 422 `bad_request` «project — строка» + до обращения к проектам. +7. **Файлы хранилища.** `files.size` и `files.read` ловят `OSError`. `FileNotFoundError` по-прежнему тихо даёт `None`. + Остальные ошибки тоже дают `None`, но пишут в лог предупреждение с путём и ошибкой. Дальше всё идёт по + существующему пути «файла нет»: `size = null` при claim, 404 при скачивании. +8. **`SDLC_HUB_HOME=""`.** `paths.home()` берёт `os.environ.get("SDLC_HUB_HOME") or <по умолчанию>`. Пустая строка + равна незаданной переменной; `home_problem`/`home_warning` уже считают так. +9. **Восстановление прерванной публикации.** В начале `release.publish`, до проверки версии, вызывается + `_recover(rd)`. Если `agent.py.prev` есть, а `agent.py` нет или его sha256 не совпадает с манифестом, тогда как + sha256 `agent.py.prev` совпадает, `agent.py.prev` возвращается на место `agent.py` с предупреждением в лог. Если + `agent.py` совпадает с манифестом, а `.prev` лежит, `.prev` удаляется. Во всех остальных случаях ничего не + трогается, а в лог пишется предупреждение. Так закрываются T2.2 и M-6: после `_recover` в `agent.py` лежит + опубликованная версия, и `os.replace(agent, prev)` не затирает годный `.prev`. +10. **Устаревшие ключи настроек.** `base_jql` и `board_id` добавляются в `modules/jira/settings.OBSOLETE`. + `ensure_defaults` удаляет их при каждом открытии, не меняя `settings_version`, как `status_labels`. Запись через + `PUT /settings` по-прежнему даёт 422 с пояснением. Агенту `board_id` давно не передаётся: его нет в `AGENT_KEYS`. + +## Risks / Trade-offs + +- Нормализация меняет то, что раньше уходило агенту дословно. Пробелы по краям идентификатора в Jira не значимы, + а текстовые поля не трогаются. +- `_recover` срабатывает только при совпадении sha256 с манифестом, поэтому неизвестное содержимое не + перезаписывается. +- Удаление `board_id` из `setting` необратимо, но значение нигде не читается. Копия базы есть в `backups/`. + +## Decisions made without discussion + +- R-1 (proposal, 26.10.2026): O-3 (`check_confirmed` считает обратную связь симметричного типа конфликтом) не + входит в пакет и переносится в пакет B. Хаб не знает, какие типы симметричны: это знает агент по + `/issueLinkType`. Ручная настройка списка типов в хабе отвергнута, она дублировала бы Jira. Цена ошибки: при + потерянном отчёте команда `ensure_link` к «связанной» задаче, связанной в обратную сторону, подтверждается не по + состоянию, а довыдачей агенту, и агент отвечает existed. Лишний вызов Jira, данные не страдают. +- R-2 (design #1): обрезаются все строковые параметры, кроме `summary`, `description`, `body`, `goal`. Обоснование: + пробелы по краям идентификатора всегда ошибка ввода, а в тексте могут быть значимы. Цена ошибки: тема с пробелами + по краям уходит в Jira как есть, Jira обрезает её сама. +- R-3 (design #2): имя вложения приводится к NFC, а не к NFD. NFC — форма, в которой имена приходят из Jira и + набираются с клавиатуры. Цена ошибки: имя вложения в Jira будет в NFC, визуально то же самое. +- R-4 (design #7): ошибка доступа к файлу хранилища трактуется как «файла нет», а не как отдельная ошибка API. + Обоснование: путь «файла нет» уже понятен агенту и клиентам. Цена ошибки: причина видна только в логе хаба. +- R-5 (design #9): `_recover` чинит только состояния, однозначно определяемые по sha256 манифеста. Остальные + оставляет как есть с предупреждением в лог. Цена ошибки: в редком сочетании сбоев владелец публикует ещё раз. +- R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы. + Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова. + Цена ошибки: нет. diff --git a/openspec/changes/hub-backend-fixes/proposal.md b/openspec/changes/hub-backend-fixes/proposal.md new file mode 100644 index 0000000..831fce2 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/proposal.md @@ -0,0 +1,45 @@ +## Why + +25.10.2026 сверка открытых follow-up issue с кодом подтвердила дефекты бэкенда хаба. Один из них уже приводит к +неверной команде агенту: ключ с пробелами из `ensure_rank` уходит в Jira как есть. Остальные проявляются при +нестандартном входе: на кривой запрос хаб отвечает 500 вместо понятного 422, один и тот же кириллический файл +превращается в два вложения, при пустом `SDLC_HUB_HOME` данные пишутся в рабочий каталог процесса, после прерванной +публикации агента может потеряться прежняя версия. Владелец с ними не сталкивался и не хочет столкнуться. Это пакет C +сверки (sdlc-hub#129). + +## What Changes + +- **Параметры команд** (#111 T-F1, #49 M-4): при постановке пакета хаб приводит параметры к каноническому виду. + Идентификаторы (ключи, имена спринтов, тип связи, логин, даты, маркер комментария, id вложения) и элементы списков + обрезаются по краям. `filename` у `ensure_attachment` обрезается и приводится к NFC, в том числе имя по умолчанию из + пути в MCP. Текст (`summary`, `description`, `body`, `goal`) хранится как задан. +- **Цифры — только ASCII** (#86 F.1): `attachment_id`, id вложений в состоянии агента, версия агента — только `0-9`. + Сейчас `"²"` проходит `isdigit()` и роняет `int()` с 500. +- **Форма тела — 422, не 500** (#42 T2.1, #94 T8.1): элемент `issues` в `POST /agent/state` должен быть объектом со + строковым `key`, `project` в `POST /wiki/sync` должен быть строкой. +- **Файлы хранилища** (#49 M-3): ошибка доступа к файлу (`PermissionError`, `IsADirectoryError` и любая `OSError`) + обрабатывается как «файла нет» с предупреждением в лог, а не 500. +- **Пустой `SDLC_HUB_HOME`** (#102 T3.2): трактуется как незаданный, каталог данных по умолчанию. +- **Публикация агента** (#39 T2.2, M-6): перед публикацией хаб восстанавливает `agent.py` из `agent.py.prev`, если + прошлая публикация прервалась; годный `agent.py.prev` не перезаписывается содержимым, не совпадающим с манифестом. +- **Уборка настроек** (#64 «выкатка»): ключи `base_jql` и `board_id` объявлены устаревшими и удаляются из `setting` + при открытии хаба, как `status_labels`/`transitions_map`. + +## Non-Goals + +- O-3 из закрытой #4 (`check_confirmed` и симметричные типы связей): хаб не знает, какие типы связей симметричны, это + знает только агент. Чинится в пакете B: агент передаёт симметричные типы, хаб учитывает их при подтверждении + по состоянию. До этого последствие безвредное: вместо подтверждения по состоянию команда ждёт отчёта агента (R-1). +- Изменения агентов, веба, MCP-инструментов (кроме имени вложения по умолчанию) и схемы базы. +- Прочие маркеры исходных issue остаются в своих issue. + +## Impact + +- Спеки: `commands` (нормализация параметров, цифровые id), `hub-api` (форма тела, файлы хранилища, публикация, + каталог данных), `data-model` (требование «Настройки»). +- Код: `modules/jira/commands.py`, `modules/jira/hooks.py`, `modules/jira/mirror.py`, `modules/jira/mcp.py`, + `modules/jira/fetch.py`, `modules/jira/attachments.py`, `modules/jira/settings.py`, `modules/wiki/api.py`, + `core/files.py`, `core/paths.py`, `core/release.py`. +- Живая база: при первом старте нового кода из `setting` уйдут `base_jql` и `board_id`. Резервная копия базы + делается штатно. +- После merge — комментарии в #111, #42, #94, #86, #49, #102, #39, #64 о закрытых маркерах; O-3 — в пакет B. diff --git a/openspec/changes/hub-backend-fixes/specs/commands/spec.md b/openspec/changes/hub-backend-fixes/specs/commands/spec.md new file mode 100644 index 0000000..23fb45c --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/commands/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Нормализация параметров при постановке +При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в +каноническом виде, после всех проверок валидации. Строковые параметры обрезаются по краям, кроме текстовых `summary`, +`description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove` +и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и +`null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC. +Агент SHALL получать параметры в том виде, в каком они сохранены. + +#### Scenario: Ключ с пробелами +- **WHEN** `POST /batches` с `ensure_rank {key: "A-1", before: " A-2 "}` +- **THEN** пакет создан, параметры команды `{key: "A-1", before: "A-2"}`; агент при `claim` получает `before = "A-2"` + +#### Scenario: Текст не обрезается +- **WHEN** `POST /batches` с `ensure_comment {key: " A-1", marker: "Итог ", body: " отступ\n"}` +- **THEN** сохранено `key = "A-1"`, `marker = "Итог"`, `body = " отступ\n"` + +#### Scenario: Имя вложения в NFD +- **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308) +- **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку + `filename` + +### Requirement: Идентификаторы из цифр — только ASCII +Хаб SHALL считать строку числовым идентификатором (`attachment_id` в `fetch_attachment`, `id` вложения в состоянии +агента, ссылка `#` в MCP, версия агента), только если она непуста и состоит из символов `0-9`. Строка из других +цифр Unicode (`"²"`, `"١٢"`) — не идентификатор: при постановке 422, в состоянии агента 422 `bad_state`, в MCP — +поиск по имени. Ответ 500 MUST NOT возникать. + +#### Scenario: Надстрочная цифра +- **WHEN** `POST /batches` с `fetch_attachment {key: "A-1", attachment_id: "²"}` +- **THEN** 422 «attachment_id — цифры id вложения из issue», пакет не создан diff --git a/openspec/changes/hub-backend-fixes/specs/data-model/spec.md b/openspec/changes/hub-backend-fixes/specs/data-model/spec.md new file mode 100644 index 0000000..da751e6 --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/data-model/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование +«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Ключи, которые модуль объявил устаревшими (у jira — `status_labels`, `transitions_map`, оставшиеся от v3 +`base_jql` и `board_id`), хаб SHALL удалять при каждом заполнении умолчаний, не меняя `settings_version`; агенту они +не передаются. `GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `sdlc-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт + +#### Scenario: Запись базовой JQL +- **WHEN** `PUT /settings {base_jql: "project = AURORA"}` +- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился + +#### Scenario: Создание проекта не трогает версию настроек +- **WHEN** создан проект `STOCKS_DRRK` +- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками + +#### Scenario: Ключи вернул процесс прежней версии +- **WHEN** база v8, процесс со старым кодом (MCP-сессия до reconnect) вставил `status_labels = {}` и + `transitions_map = {}`, затем вызван `ensure_defaults` текущего кода (старт хаба, новая MCP-сессия) +- **THEN** ключей в `setting` нет, `settings_version` прежний + +#### Scenario: Ключей статуса по факту нет +- **WHEN** хаб открыл базу v8 +- **THEN** `GET /settings` не содержит `status_labels` и `transitions_map`; `GET /agent/settings` агенту `jira` их не + отдаёт + +#### Scenario: Ключи v3 в живой базе +- **WHEN** в `setting` лежат `base_jql` и `board_id` (база, поднятая с v3), хаб открыл базу +- **THEN** ключей `base_jql` и `board_id` в `setting` нет, `settings_version` прежний; `GET /settings` отдаёт вычисленную + `base_jql`, `board_id` в ответе нет; `PUT /settings {board_id: 1}` — 422 «доска Jira — свойство проекта» diff --git a/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md b/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md new file mode 100644 index 0000000..2a60a5b --- /dev/null +++ b/openspec/changes/hub-backend-fixes/specs/hub-api/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Форма тела запроса +На тело неверной формы хаб SHALL отвечать 422, а не 500. В `POST /agent/state` поле `issues` SHALL быть списком, +каждый его элемент — объектом с непустым строковым `key`. Иначе 422 `bad_state` с номером элемента, и пачка не +применяется. В `POST /wiki/sync` поле `project` в теле, если задано, SHALL быть строкой. Иначе 422 `bad_request` +«project — строка». + +#### Scenario: Элемент состояния не объект +- **WHEN** агент шлёт `POST /agent/state {issues: ["A-1"]}` +- **THEN** 422 `bad_state` «issues[0] — объект задачи с key», зеркало не изменилось + +#### Scenario: Проект вики не строкой +- **WHEN** `POST /wiki/sync {project: 5}` +- **THEN** 422 `bad_request` «project — строка», пакет не создан + +### Requirement: Ошибка доступа к файлу хранилища +Если файл хранилища вложений не читается (`PermissionError`, `IsADirectoryError` или другая ошибка ОС), хаб SHALL +обрабатывать его как отсутствующий: при `claim` `ensure_attachment` получает `size = null`, `GET /files/` и +скачивание вложения отвечают 404, как для удалённого файла. В лог хаба SHALL писаться предупреждение с путём и +ошибкой. `FileNotFoundError` в лог не пишется. + +#### Scenario: Нет прав на файл +- **WHEN** у файла `files/` сняты права на чтение, агент берёт `ensure_attachment` с этим `file` +- **THEN** `claim` отвечает 200, у команды `size = null`; в логе — предупреждение с путём + +### Requirement: Пустой SDLC_HUB_HOME +Переменная `SDLC_HUB_HOME`, заданная пустой строкой, SHALL трактоваться как незаданная: каталог данных — +`~/.local/share/sdlc-hub/`, проверка прежнего каталога выполняется. Хаб MUST NOT писать данные в рабочий каталог +процесса из-за пустой переменной. + +#### Scenario: Пустая переменная +- **WHEN** `SDLC_HUB_HOME= sdlc-hub tokens` +- **THEN** база открыта из `~/.local/share/sdlc-hub/hub.sqlite`; в рабочем каталоге не появился `hub.sqlite` + +### Requirement: Восстановление прерванной публикации агента +Перед публикацией версии агента модуля хаб SHALL приводить `release/<модуль>/` к опубликованному состоянию по +`manifest.json`. Если `agent.py.prev` есть, а `agent.py` нет или не совпадает по sha256 с манифестом, тогда как +`agent.py.prev` совпадает, `agent.py.prev` SHALL возвращаться на место `agent.py`. Если `agent.py` совпадает с +манифестом, оставшийся `agent.py.prev` удаляется. В других сочетаниях файлы не трогаются, в лог пишется +предупреждение. Годный `agent.py.prev`, совпадающий с манифестом, MUST NOT перезаписываться другим содержимым. + +#### Scenario: Сбой между переименованием и записью +- **WHEN** опубликована v5, прошлая публикация упала после переименования: `agent.py` нет, `agent.py.prev` — v5, + манифест — v5; новая публикация v6 снова падает при записи +- **THEN** в `release/jira/` — `agent.py` v5 и манифест v5, опубликованной остаётся v5 + +#### Scenario: Сбой поверх неудачного возврата +- **WHEN** `agent.py` — недописанный v6, `agent.py.prev` — v5, манифест — v5; запущена публикация v6 +- **THEN** перед записью `agent.py` восстановлен из `.prev` (v5); при сбое записи опубликованной остаётся v5, а `.prev` + не затёрт недописанным файлом From 5fbfe3a8d4ac3379327dec652eabd5bb283e4e76 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:08:41 +0400 Subject: [PATCH 02/12] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=20C=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20=D1=85=D0=B0=D0=B1=D0=B0=20hub-backend-fixes=20(#129),?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=201=E2=80=936;=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D0=BA=D0=B0=20=E2=80=94=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-26-hub-backend-fixes.md | 311 ++++++++++++++++++ openspec/changes/hub-backend-fixes/design.md | 10 +- .../hub-backend-fixes/specs/commands/spec.md | 14 +- 3 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-26-hub-backend-fixes.md diff --git a/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md b/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md new file mode 100644 index 0000000..1c35bec --- /dev/null +++ b/docs/superpowers/plans/2026-10-26-hub-backend-fixes.md @@ -0,0 +1,311 @@ +# Пакет C бэкенда хаба — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** закрыть подтверждённые дефекты бэкенда хаба из пакета C (sdlc-hub#129): нормализация параметров команд, ASCII-цифры, 422 вместо 500, ошибки доступа к файлам, пустой `SDLC_HUB_HOME`, восстановление публикации агента, уборка ключей `base_jql`/`board_id`. + +**Architecture:** правки точечные, в существующих функциях. Нормализация — одна функция `commands.normalize` внутри `commands.validate` (через неё идут все пути постановки: `queue.create_batch` → `hooks.validate`). Восстановление публикации — `release._recover` в начале `release.publish`. Схема базы и контракт с агентами не меняются. + +**Tech Stack:** Python 3.13 (uv), pytest, stdlib (`unicodedata`, `http.server`, `sqlite3`). + +**Spec:** `openspec/changes/hub-backend-fixes/` (proposal.md, design.md — решения 1–10, specs/commands, specs/hub-api, specs/data-model). + +## Global Constraints + +- Только хаб (`src/sdlc_hub/`, `tests/`); агенты, веб (`web/`) и схема базы (`SCHEMA_VERSION = 9`) не меняются. +- Граница ядра: новые имена в `core/` без терминов модулей (`jira`, `wiki`, `eva`); проверяют `tests/test_registry.py`. +- Тексты ошибок — по-русски, в стиле существующих: «команда {i} ({ctype}): нет …; ожидаются …». +- Текстовые параметры `summary`, `description`, `body`, `goal` не обрезаются (design R-2). +- `uv run pytest` — зелёным считается только весь набор; в конце — `make check`. +- Соединения SQLite в тестах — через фикстуры `open_db`/`conn`, иначе явный `close()`. + +## Review Focus + +1. Временный ключ с пробелами (`epic_key: " new:1 "`, `keys: [" new:1"]` в `refresh`) должен подставляться так же, как без пробелов. Тест в Task 1. +2. Повтор команды (`POST /commands/{id}/retry`) и пакет, поставленный через MCP `batch_create`, должны получать те же нормализованные параметры. Тест на MCP в Task 1 (имя вложения по умолчанию из пути в NFD). +3. Нестроковые значения там, где ждут цифры (`id: 7` числом в состоянии агента, `attachment_id: 7`), — 422, а не 500. Тест в Task 2. +4. `issues` не списком (`{"issues": {"key": "A"}}`, `{"issues": "A-1"}`) в `POST /agent/state` — 422, а не 500 на итерации. Тест в Task 3. +5. Первая публикация после сбоя, когда манифеста ещё нет (`agent.py.prev` есть, `manifest.json` нет), — ничего не трогать, публикация идёт как первая. Тест в Task 5. + +--- + +### Task 1: Нормализация параметров команд (#111 T-F1, #49 M-4) + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/commands.py` (`validate`, новая `normalize`) +- Modify: `src/sdlc_hub/modules/jira/mcp.py:351` (поиск вложения по имени), `:481` (имя по умолчанию из пути) +- Test: `tests/test_commands.py`, `tests/test_api.py`, `tests/test_mcp.py` + +**Interfaces:** +- Produces: `commands.normalize(ctype: str, params: dict) -> dict` — новый словарь; строки обрезаны, кроме `TEXT_PARAMS = {"summary", "description", "body", "goal"}`; элементы `LIST_PARAMS` и `target`-списка обрезаны; `filename` — `unicodedata.normalize("NFC", s.strip())`; нестроковые значения как есть. + +- [ ] **Step 1: Падающие тесты в `tests/test_commands.py`** + +```python +def test_normalize_trims_identifiers_keeps_text(): + out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}]) + assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"} + +def test_normalize_rank_before(): + out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}]) + assert out[0]["params"] == {"key": "A-1", "before": "A-2"} + +def test_normalize_temp_key_with_spaces(): + out = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}}, + {"type": "refresh", "params": {"keys": [" new:1"]}}]) + assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"] + +def test_normalize_blank_required_is_missing(): + with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"): + commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}]) + +def test_normalize_blank_list_item(): + with pytest.raises(CommandError, match="labels_add — без пустых элементов"): + commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}]) + +def test_normalize_filename_nfc(): + nfd = unicodedata.normalize("NFD", "отчёт.pdf") + out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}]) + assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf") + +def test_normalize_does_not_mutate_input(): + params = {"key": " A-1 ", "target": [" В работе ", "Готово "]} + out = commands.validate([{"type": "ensure_status", "params": params}]) + assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"] +``` + +В `tests/test_api.py` — через живой API: `POST /batches` с `ensure_rank {key: "X-1", before: " X-2 "}` (задачи в зеркале), затем `claim` агентом отдаёт `params["before"] == "X-2"`. + +В `tests/test_mcp.py` — MCP `batch_create` с `ensure_attachment {key, path}` к файлу, чьё имя записано в NFD: в поставленной команде `filename` в NFC. MCP-инструмент `attachment` по имени в NFC находит вложение, у которого в зеркале `filename` в NFD (и наоборот). + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_commands.py -k normalize -v` +Expected: FAIL (параметры не обрезаны, `filename` не в NFC) + +- [ ] **Step 3: Реализовать `normalize` и встроить в `validate`** + +Порядок в `validate` (design #1): после проверки типов значений (`bad = …`) — `params = normalize(ctype, params)`. Затем повторная проверка обязательных параметров по нормализованным значениям, тем же текстом «нет …; ожидаются …». Затем проверка пустых элементов списков «<параметр> — без пустых элементов». Дальше всё как было (`_rule_error`, ensure_fields без полей, temp_key, временные ключи, повтор темы); в `out` кладётся нормализованный `params`. В `mcp.py:351` сравнивать `unicodedata.normalize("NFC", …)` обеих сторон. В `mcp.py:481` имя по умолчанию тоже привести к NFC; оно всё равно пройдёт `validate`, но в тексте ответа MCP должно быть то же имя. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest tests/test_commands.py tests/test_api.py tests/test_mcp.py -q` и `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/sdlc_hub/modules/jira/commands.py src/sdlc_hub/modules/jira/mcp.py tests/test_commands.py tests/test_api.py tests/test_mcp.py +git commit -m "Команды: параметры обрезаются при постановке, имя вложения — NFC (#129, #111 T-F1, #49 M-4)" +``` + +### Task 2: Идентификаторы из цифр — только ASCII (#86 F.1) + +**Files:** +- Modify: `src/sdlc_hub/core/http.py` (новая `is_digits` рядом с `int_group`) +- Modify: `src/sdlc_hub/modules/jira/hooks.py:43`, `mirror.py:46`, `attachments.py:43`, `fetch.py:34`, `mcp.py:348`, `history.py:17`, `mcp.py:48`, `mcp.py:220` +- Test: `tests/test_commands.py` или `tests/test_api.py`, `tests/test_mirror.py`, `tests/test_attachments.py` + +**Interfaces:** +- Produces: `core.http.is_digits(value) -> bool` — `True` только для непустой `str` из `0-9` (`isinstance(value, str) and value.isascii() and value.isdigit()`); для не-строк `False`. + +- [ ] **Step 1: Падающие тесты** + +- `is_digits`: `"123"` → True; `""`, `"²"`, `"١٢"`, `"12a"`, `7`, `None` → False. +- `POST /batches` с `fetch_attachment {key: "X-1", attachment_id: "²"}` → 422 «attachment_id — цифры id вложения из issue», пакет не создан. +- `POST /agent/state` с вложением `{"id": "²", …}` → 422 `bad_state`; с `{"id": 7, …}` (число) → 422 `bad_state`. +- `attachments` сортировка (`attachments.py:43`) с `attachment_id = "²"` не бросает, такой id уходит в хвост (как нецифровой). +- `fetch.py:34`: версия агента `"²"` трактуется как неизвестная и не блокирует, без исключения. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_api.py tests/test_mirror.py tests/test_attachments.py -k "digit or ascii" -v` +Expected: FAIL (500 / ValueError от `int("²")`) + +- [ ] **Step 3: Добавить `is_digits` и заменить им `str.isdigit()` во всех перечисленных местах** + +В `fetch.py:34` — `is_digits(str(v))`; в разборе дат (`history.py:17`, `mcp.py:48`, `mcp.py:220`) — `is_digits` вместо `.isdigit()` у среза. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Цифровые id и версия агента — только ASCII 0-9, без 500 на «²» (#129, #86 F.1)" +``` + +### Task 3: Форма тела запроса — 422 (#42 T2.1, #94 T8.1) + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mirror.py` (новая `check_issues`), `src/sdlc_hub/modules/jira/api.py:23-31` (`h_agent_state`), `src/sdlc_hub/modules/wiki/api.py:96-107` (`h_sync`) +- Test: `tests/test_api.py`, `tests/test_wiki_api.py` + +**Interfaces:** +- Produces: `mirror.check_issues(issues) -> None` — `ValueError("issues — список задач")`, если не список; `ValueError(f"issues[{n}] — объект задачи с key")`, если элемент не `dict` или `key` не непустая строка. + +- [ ] **Step 1: Падающие тесты** + +```python +@pytest.mark.parametrize("issues, detail", [ + (["X-1"], "issues[0] — объект задачи с key"), + ([{"summary": "без ключа"}], "issues[0] — объект задачи с key"), + ([{"key": 5}], "issues[0] — объект задачи с key"), + ({"key": "X-1"}, "issues — список задач"), + ("X-1", "issues — список задач"), +]) +def test_agent_state_bad_issue_shape_422(hub, issues, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": issues, "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail +``` + +В `tests/test_wiki_api.py`: `err(api.client.post, "/wiki/sync", {"project": 5})` → `status == 422`, `error == "bad_request"`, `detail == "project — строка"`; пакета не создано. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_api.py -k bad_issue_shape tests/test_wiki_api.py -k "project" -v` +Expected: FAIL (500) + +- [ ] **Step 3: Реализовать** + +`check_issues(issues)` вызывается первой в блоке `try` `h_agent_state`; `issues = body.get("issues") or []` не меняется. В `h_sync` проверить `body.get("project")`: если задан и не `str`, то `ApiError(422, "bad_request", "project — строка")` до `_project`. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "API: состояние агента и синхронизация вики неверной формы — 422, а не 500 (#129, #42 T2.1, #94 T8.1)" +``` + +### Task 4: Файлы хранилища и пустой SDLC_HUB_HOME (#49 M-3, #102 T3.2) + +**Files:** +- Modify: `src/sdlc_hub/core/files.py:43-59` (`size`, `read`), `src/sdlc_hub/core/paths.py:10` (`home`) +- Test: `tests/test_files.py`, `tests/test_layout.py`, `tests/test_api.py` + +- [ ] **Step 1: Падающие тесты** + +- `test_files.py`: `size`/`read` при `PermissionError` (monkeypatch `Path.stat` / `Path.read_bytes` бросает `PermissionError(13, "Permission denied")`) → `None`, в `caplog` предупреждение с путём; при `IsADirectoryError` (на месте файла каталог `root/`) → `None`; `FileNotFoundError` → `None` без записи в лог. +- `test_api.py`: файл `ensure_attachment` заменён каталогом → `claim` 200, `params["size"] is None`. +- `test_layout.py`: + +```python +def test_empty_env_home_is_default(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.setenv("SDLC_HUB_HOME", "") + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub" +``` + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_files.py tests/test_layout.py -v` +Expected: FAIL (PermissionError наружу; `Path("")`) + +- [ ] **Step 3: Реализовать** + +`files.size`/`files.read`: `except FileNotFoundError: return None`, затем `except OSError as e: log.warning("файл хранилища %s не читается: %s", <путь>, e); return None` (логгер модуля, как в `paths.py`). `paths.home()`: `os.environ.get("SDLC_HUB_HOME") or str(Path.home() / ".local" / "share" / "sdlc-hub")`. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Хранилище: нечитаемый файл — «файла нет» с предупреждением; пустой SDLC_HUB_HOME — по умолчанию (#129, #49 M-3, #102 T3.2)" +``` + +### Task 5: Восстановление прерванной публикации агента (#39 T2.2, M-6) + +**Files:** +- Modify: `src/sdlc_hub/core/release.py` (новая `_recover`, вызов в `publish`) +- Test: `tests/test_release.py` + +**Interfaces:** +- Produces: `release._recover(rd: Path) -> None` — вызывается в `publish` сразу после `rd = Path(release_dir)`, до `current(rd)`. + +- [ ] **Step 1: Падающие тесты** (помощники `agent_file`, `_listing`, `_fail_replace_into` уже есть в файле) + +- `test_recover_missing_agent_restores_prev`: опубликована v5; руками `os.replace(rd/"agent.py", rd/"agent.py.prev")`; публикация v6 падает при записи (`monkeypatch.setattr(release.os, "fsync", no_space)`) → после неё `rd/"agent.py"` — байты v5, `release.load(rd)["version"] == 5`, `agent.py.prev` нет. +- `test_recover_partial_agent_keeps_good_prev`: опубликована v5; `agent.py.prev` = байты v5, `agent.py` = `b"AGENT_VERSION = 6\n# обрыв"`; публикация v6 падает при записи → `agent.py` = байты v5, `.prev` нет или равен v5 (не затёрт обрывком); `release.load(rd)["version"] == 5`. +- `test_recover_success_after_crash`: то же исходное состояние, публикация v6 успешна → в каталоге только `agent.py` (v6) и `manifest.json`. +- `test_recover_leftover_prev_removed`: `agent.py` совпадает с манифестом, рядом `agent.py.prev` → после успешной публикации v6 `.prev` нет (уже так), а при падении v6 до переименования `.prev`, совпадающий с манифестом, не мешает. +- `test_recover_unknown_state_untouched` (Review Focus 5): `manifest.json` нет, `agent.py.prev` есть → `_recover` ничего не трогает, в `caplog` предупреждение; публикация v1 проходит как первая. + +- [ ] **Step 2: Запустить — падают** + +Run: `uv run pytest tests/test_release.py -k recover -v` +Expected: FAIL (после сбоя `agent.py` нет / `.prev` затёрт) + +- [ ] **Step 3: Реализовать `_recover` по design #9** + +sha256 файлов сравнивать с `manifest.json` (читать его тем же способом, что `current`, без исключений наружу). Случаи: +- `.prev` совпадает с манифестом, а `agent.py` нет или не совпадает → `os.replace(prev, agent)` и `log.warning`; +- `agent.py` совпадает → лишний `.prev` удалить; +- иначе, если `.prev` есть, → `log.warning` и ничего не трогать. + +`OSError` внутри `_recover` → `log.warning`, публикация продолжается. + +- [ ] **Step 4: Запустить — зелёные, весь набор** + +Run: `uv run pytest -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Публикация агента: прерванная прошлая публикация восстанавливается по манифесту (#129, #39 T2.2, M-6)" +``` + +### Task 6: Уборка ключей base_jql и board_id (#64 «выкатка») и итоговая проверка + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/settings.py:29` (`OBSOLETE`), докстринг `validate_setting` при необходимости +- Test: `tests/test_settings.py` + +- [ ] **Step 1: Падающий тест** + +```python +def test_v3_keys_removed_on_open(conn): + v0 = settings.get(conn, "settings_version") + conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')") + settings.ensure_defaults(conn) + stored = {r["key"] for r in conn.execute("SELECT key FROM setting")} + assert {"board_id", "base_jql"}.isdisjoint(stored) + assert settings.get(conn, "settings_version") == v0 + with pytest.raises(ValueError, match="доска Jira — свойство проекта"): + settings.set_(conn, "board_id", 1) +``` + +Плюс через API: `GET /settings` после открытия базы с этими ключами отдаёт вычисленную `base_jql`, а `board_id` в ответе нет. + +- [ ] **Step 2: Запустить — падает** + +Run: `uv run pytest tests/test_settings.py -k v3_keys -v` +Expected: FAIL (ключи остались) + +- [ ] **Step 3: `OBSOLETE = ("status_labels", "transitions_map", "base_jql", "board_id")`** + +Проверить, что шаг миграции v7 → v8 (`settings.py:93-97`, использует `OBSOLETE`) от расширения кортежа не ломается: на v7-базе он удалит и эти ключи, это допустимо. Тест миграции `tests/test_db.py` должен остаться зелёным. + +- [ ] **Step 4: Итоговая проверка** + +Run: `uv run pytest -q` и `make check` +Expected: всё зелёное (pytest, lint, tsc, vitest) + +- [ ] **Step 5: Commit** + +```bash +git commit -am "Настройки: ключи v3 base_jql и board_id удаляются при открытии хаба (#129, #64)" +``` diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md index 1e76670..9d9412a 100644 --- a/openspec/changes/hub-backend-fixes/design.md +++ b/openspec/changes/hub-backend-fixes/design.md @@ -6,9 +6,13 @@ ## Decisions -1. **Нормализация параметров — один шаг в `validate`.** После всех проверок `validate` кладёт в пакет параметры, - приведённые функцией `normalize(ctype, params) -> dict` (`modules/jira/commands.py`). Проверки на пустоту - (`.strip()`) остаются как есть. Обрезаются строковые параметры всех типов, кроме текстовых `summary`, +1. **Нормализация параметров — один шаг в `validate`.** Сразу после проверки типов значений (`_type_error`) + `validate` заменяет параметры результатом `normalize(ctype, params) -> dict` (`modules/jira/commands.py`), и все + дальнейшие проверки (обязательные параметры, `_rule_error`, временные ключи, повтор темы) идут по нормализованным + значениям. Иначе `epic_key: " new:1 "` прошёл бы как постоянный ключ и стал временным уже после проверки + объявления, а ключ из пробелов прошёл бы проверку на пустоту. Обязательный параметр, ставший пустым, даёт ту же + ошибку «нет <параметр>», пустой элемент списка — «<параметр> — без пустых элементов». Существующие `.strip()` в + правилах остаются, они безвредны. Обрезаются строковые параметры всех типов, кроме текстовых `summary`, `description`, `body`, `goal`. Это ключи, `temp_key`, `sprint`, `rename_to`, `move_open_to`, `link_type`, `assignee`, `priority`, даты, `marker`, `attachment_id`, `file`, `filename`, `issue_type`, `target` (строка или элементы списка). Элементы списков `labels`, `labels_add`, `labels_remove`, `keys` тоже обрезаются. Числа, diff --git a/openspec/changes/hub-backend-fixes/specs/commands/spec.md b/openspec/changes/hub-backend-fixes/specs/commands/spec.md index 23fb45c..da7eae9 100644 --- a/openspec/changes/hub-backend-fixes/specs/commands/spec.md +++ b/openspec/changes/hub-backend-fixes/specs/commands/spec.md @@ -2,7 +2,10 @@ ### Requirement: Нормализация параметров при постановке При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в -каноническом виде, после всех проверок валидации. Строковые параметры обрезаются по краям, кроме текстовых `summary`, +каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок: +обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи +`new:` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт +422 «<параметр> — без пустых элементов». Строковые параметры обрезаются по краям, кроме текстовых `summary`, `description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove` и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и `null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC. @@ -16,6 +19,15 @@ - **WHEN** `POST /batches` с `ensure_comment {key: " A-1", marker: "Итог ", body: " отступ\n"}` - **THEN** сохранено `key = "A-1"`, `marker = "Итог"`, `body = " отступ\n"` +#### Scenario: Временный ключ с пробелами +- **WHEN** пакет `ensure_issue {temp_key: "new:1", …}`, затем `ensure_fields {key: "A-5", epic_key: " new:1 "}` +- **THEN** пакет создан, `epic_key` сохранён как `new:1` и подставляется настоящим ключом после выполнения + `ensure_issue` + +#### Scenario: Ключ из пробелов +- **WHEN** `POST /batches` с `ensure_status {key: " ", target: "Готово"}` +- **THEN** 422 «команда 1 (ensure_status): нет key; ожидаются key, target» + #### Scenario: Имя вложения в NFD - **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308) - **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку From abbcf0cc1f56819bc3559ac4d432bd40d980d68a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:20:17 +0400 Subject: [PATCH 03/12] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B:=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D1=8B=20=D0=BE=D0=B1=D1=80=D0=B5=D0=B7=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BA=D0=B5,=20=D0=B8=D0=BC=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=E2=80=94=20NFC=20?= =?UTF-8?q?(#129,=20#111=20T-F1,=20#49=20M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/modules/jira/commands.py | 36 +++++++++++++++++++ src/sdlc_hub/modules/jira/mcp.py | 7 ++-- tests/test_api.py | 9 +++++ tests/test_commands.py | 52 +++++++++++++++++++++++++-- tests/test_mcp.py | 21 +++++++++++ tests/test_queue.py | 2 +- 6 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index a5317f6..f34daa8 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -3,6 +3,7 @@ проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" import math import re +import unicodedata from datetime import date from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) @@ -56,6 +57,7 @@ WRITE_TYPES = [t for t in SPEC if t not in ("refresh", "fetch_attachment")] LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} BOOL_PARAMS = {"replace"} NUMBER_PARAMS = {"story_points"} +TEXT_PARAMS = {"summary", "description", "body", "goal"} # содержательный текст: пробелы и отступы значимы SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch) DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") @@ -105,6 +107,33 @@ def _type_error(ctype, name, value) -> str | None: return None if isinstance(value, str) else f"{name} — строка" +def normalize(ctype, params: dict) -> dict: + """Новый словарь параметров: строки и элементы списков обрезаны (кроме TEXT_PARAMS), filename — NFC (#129). + Нестроковые значения остаются как есть; вход не меняется.""" + def trim(v): + return v.strip() if isinstance(v, str) else v + + out = {} + for name, value in params.items(): + if name in TEXT_PARAMS: + out[name] = value + elif name == "filename" and isinstance(value, str): + out[name] = unicodedata.normalize("NFC", value.strip()) + elif isinstance(value, list): + out[name] = [trim(v) for v in value] + else: + out[name] = trim(value) + return out + + +def _blank_item_error(ctype, params) -> str | None: + for name, value in params.items(): + is_list = name in LIST_PARAMS or (ctype == "ensure_status" and name == "target") + if is_list and isinstance(value, list) and any(v == "" for v in value): + return f"{name} — без пустых элементов" + return None + + def _date(value) -> date | None: """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None.""" if not isinstance(value, str) or not DATE_RE.match(value): @@ -205,6 +234,13 @@ def validate(commands) -> list[dict]: bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] if bad: raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) + params = normalize(ctype, params) + missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] + if missing: + raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; {_expected(spec)}") + blank = _blank_item_error(ctype, params) + if blank: + raise CommandError(f"команда {i} ({ctype}): {blank}") rule = _rule_error(ctype, params) if rule: raise CommandError(f"команда {i} ({ctype}): {rule}") diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 5a97874..3120304 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -6,6 +6,7 @@ import base64 import json import stat import time +import unicodedata from datetime import datetime, timedelta from pathlib import Path @@ -348,7 +349,8 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else [] prefix = "" if not found: - found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху + want = unicodedata.normalize("NFC", attachment.strip()) + found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] # хаб отдаёт вложения новыми сверху if len(found) > 1: prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n" if not found: @@ -478,7 +480,8 @@ def _attach_files(client, commands): params = {k: v for k, v in cmd["params"].items() if k != "path"} up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) params["file"] = up["file"] - params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name + params["filename"] = params.get("filename") or unicodedata.normalize( + "NFC", Path(cmd["params"]["path"]).expanduser().name) out.append({**cmd, "params": params}) return out diff --git a/tests/test_api.py b/tests/test_api.py index efc2a39..a99f414 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1270,3 +1270,12 @@ def test_agent_state_empty_batch_with_boards(hub): assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 finally: c.close() + + +def test_rank_before_trimmed_on_claim(hub): + hub["agent"].post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}, + {"key": "X-2", "summary": "Два", "labels": []}], "links": []}) + hub["client"].post("/batches", {"title": "r", "commands": [ + {"type": "ensure_rank", "params": {"key": "X-1", "before": " X-2 "}}]}) + cmd = hub["agent"].post("/agent/claim", {})["command"] + assert cmd["type"] == "ensure_rank" and cmd["params"]["before"] == "X-2" diff --git a/tests/test_commands.py b/tests/test_commands.py index a55c623..b9393e6 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,4 +1,5 @@ import re +import unicodedata import pytest @@ -284,7 +285,7 @@ def test_new_types_validate_ok(): ({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"), ({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"), - ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "sprint — непустое имя"), + ({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "нет sprint; ожидаются sprint"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), (state(state="open"), "state — active или closed"), @@ -299,7 +300,7 @@ def test_new_types_validate_ok(): ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError - ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"), + ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "нет sprint; ожидаются sprint, state"), (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"), (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"), @@ -330,3 +331,50 @@ def test_story_points_confirmation(conn): # R-16 assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True + + +def test_normalize_trims_identifiers_keeps_text(): + out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}]) + assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"} + + +def test_normalize_rank_before(): + out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}]) + assert out[0]["params"] == {"key": "A-1", "before": "A-2"} + + +def test_normalize_temp_key_with_spaces(): + out = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}}, + {"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}}, + {"type": "refresh", "params": {"keys": [" new:1"]}}]) + assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"] + + +def test_normalize_blank_required_is_missing(): + with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"): + commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}]) + + +def test_normalize_blank_list_item(): + with pytest.raises(CommandError, match="labels_add — без пустых элементов"): + commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}]) + + +def test_normalize_filename_nfc(): + nfd = unicodedata.normalize("NFD", "отчёт.pdf") + assert nfd != unicodedata.normalize("NFC", "отчёт.pdf") + out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}]) + assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf") + + +def test_normalize_does_not_mutate_input(): + params = {"key": " A-1 ", "target": [" В работе ", "Готово "]} + out = commands.validate([{"type": "ensure_status", "params": params}]) + assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"] + + +def test_normalize_summary_description_goal_untouched(): + out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", + "summary": " Т ", "description": " d\n"}}]) + assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c50c017..11a4735 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,5 +1,6 @@ import inspect import asyncio +import unicodedata import hashlib import json import os @@ -952,3 +953,23 @@ def test_batch_create_description_lists_sprint_types(): doc = jmcp.batch_create.__doc__ for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): assert word in doc + + +def test_batch_create_default_filename_nfc(live, tmp_path): + """M-4 #49: имя по умолчанию из пути (NFD с macOS) приводится к NFC.""" + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) + nfd = unicodedata.normalize("NFD", "отчёт.pdf") + f = tmp_path / nfd + f.write_bytes(b"pdf") + out = jmcp.batch_create("файл", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) + assert _batch_commands(out)[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf") + + +@pytest.mark.parametrize("stored, asked", [("NFD", "NFC"), ("NFC", "NFD")]) +def test_attachment_name_match_normalized(downloads, monkeypatch, stored, asked): + name = "отчёт.pdf" + att = _att("31337", unicodedata.normalize(stored, name), 3, state="ready") + hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc") + _use(monkeypatch, hub) + out = asyncio.run(jmcp.attachment(ATT_KEY, unicodedata.normalize(asked, name))) + assert not out.startswith("у ") and ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls diff --git a/tests/test_queue.py b/tests/test_queue.py index d6544f0..5f95a77 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -601,7 +601,7 @@ def test_attachment_without_file_rejected(conn, store): @pytest.mark.parametrize("params, msg", [ ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), - ({"filename": " "}, "команда 1: filename пустой"), + ({"filename": " "}, "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"), ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), ({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), From 4a6855d70b73dabf6b8e1af4b5f61f5b0a839995 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:27:47 +0400 Subject: [PATCH 04/12] =?UTF-8?q?=D0=A6=D0=B8=D1=84=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20id=20=D0=B8=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20ASCII=200-9,=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20500=20=D0=BD=D0=B0=20=C2=AB=C2=B2=C2=BB=20(#129,?= =?UTF-8?q?=20#86=20F.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/http.py | 6 ++++++ src/sdlc_hub/modules/jira/attachments.py | 3 ++- src/sdlc_hub/modules/jira/fetch.py | 3 ++- src/sdlc_hub/modules/jira/history.py | 3 ++- src/sdlc_hub/modules/jira/hooks.py | 3 ++- src/sdlc_hub/modules/jira/mcp.py | 7 ++++--- src/sdlc_hub/modules/jira/mirror.py | 3 ++- tests/test_api.py | 20 ++++++++++++++++++++ tests/test_attachments.py | 6 ++++++ tests/test_commands.py | 7 +++++++ tests/test_mirror.py | 1 + tests/test_queue.py | 6 ++++++ 12 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/sdlc_hub/core/http.py b/src/sdlc_hub/core/http.py index 76c590f..4abe2ef 100644 --- a/src/sdlc_hub/core/http.py +++ b/src/sdlc_hub/core/http.py @@ -30,6 +30,12 @@ def int_group(m, i=1) -> int: return int(m.group(i)) +def is_digits(value) -> bool: + """Непустая строка только из ASCII-цифр 0-9. str.isdigit() принимает «²» и арабо-индийские цифры, на которых int() + падает (#86 F.1); идентификаторы из цифр проверять только этим.""" + return isinstance(value, str) and value.isascii() and value.isdigit() + + def int_param(q, name, default) -> int: try: return int(q.get(name, default)) diff --git a/src/sdlc_hub/modules/jira/attachments.py b/src/sdlc_hub/modules/jira/attachments.py index 633e8ee..743fe42 100644 --- a/src/sdlc_hub/modules/jira/attachments.py +++ b/src/sdlc_hub/modules/jira/attachments.py @@ -4,6 +4,7 @@ import re from ...core import files +from ...core.http import is_digits # lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки; # после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя @@ -40,7 +41,7 @@ def ref_names(text: str | None) -> list[str]: def _newest_key(item: dict): aid = str(item["attachment_id"]) - return item.get("created_at") or "", int(aid) if aid.isdigit() else -1, aid + return item.get("created_at") or "", int(aid) if is_digits(aid) else -1, aid def resolve(names: list[str], items: list[dict]) -> list[str]: diff --git a/src/sdlc_hub/modules/jira/fetch.py b/src/sdlc_hub/modules/jira/fetch.py index 79a4a1a..780b190 100644 --- a/src/sdlc_hub/modules/jira/fetch.py +++ b/src/sdlc_hub/modules/jira/fetch.py @@ -1,6 +1,7 @@ """Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки.""" from ...core import db, queue from ...core.errors import CommandError, HttpMapped +from ...core.http import is_digits from . import attachments, commands FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment @@ -31,7 +32,7 @@ def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> di if info["state"] in ("ready", "fetching"): return info v = queue.agent_version(conn, "jira") - if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем + if v is not None and is_digits(str(v)) and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}") cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}]) hooks.check_setup(conn, cmds, files_dir) diff --git a/src/sdlc_hub/modules/jira/history.py b/src/sdlc_hub/modules/jira/history.py index 7721b01..256a62e 100644 --- a/src/sdlc_hub/modules/jira/history.py +++ b/src/sdlc_hub/modules/jira/history.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone from . import projects from ...core import db, settings +from ...core.http import is_digits SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5) @@ -14,7 +15,7 @@ def jira_utc(value) -> str | None: if not isinstance(value, str) or not value: return None s = value[:-1] + "+00:00" if value.endswith("Z") else value - if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM + if len(s) > 5 and s[-5] in "+-" and is_digits(s[-4:]): # ±HHMM → ±HH:MM s = s[:-2] + ":" + s[-2:] try: d = datetime.fromisoformat(s) diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index 17e2a9e..c9a1175 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -3,6 +3,7 @@ import json import logging from ...core import files, projects, queue, settings +from ...core.http import is_digits from ...core.errors import CommandError from ...core.modules import CommandHooks from . import attachments, commands, history, mirror, refresh @@ -40,7 +41,7 @@ def check_setup(conn, cmds, files_dir) -> None: # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") if c["type"] == "fetch_attachment": - if not p["attachment_id"].isdigit(): + if not is_digits(p["attachment_id"]): raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue") row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?", (p["attachment_id"], p["key"])).fetchone() diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 3120304..70efa36 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -15,6 +15,7 @@ from mcp.server.fastmcp import FastMCP from ...core import mcp as core from ...core.client import HubError from ...core.files import MAX_SIZE +from ...core.http import is_digits from .attachments import fmt_size from .commands import describe, is_temp @@ -46,7 +47,7 @@ def fmt_day(value) -> str: text = value.strip() if text.endswith(("Z", "z")): text = text[:-1] + "+00:00" - if len(text) > 5 and text[-5] in "+-" and text[-4:].isdigit(): + if len(text) > 5 and text[-5] in "+-" and is_digits(text[-4:]): text = text[:-2] + ":" + text[-2:] try: at = datetime.fromisoformat(text) @@ -218,7 +219,7 @@ DATE_LABELS = {"duedate": "срок", "planned_start": "плановое нач def _day(value, text) -> str: """Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—».""" - if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit(): + if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and is_digits(value[:4]): return f"{value[8:10]}.{value[5:7]}" return core.value(text) @@ -346,7 +347,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: d = await asyncio.to_thread(client.get, f"/issues/{key}") atts = d.get("attachments") or [] ref = attachment.strip().lstrip("#") - found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else [] + found = [a for a in atts if str(a["attachment_id"]) == ref] if is_digits(ref) else [] prefix = "" if not found: want = unicodedata.normalize("NFC", attachment.strip()) diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 90da1a3..59f7b2a 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -5,6 +5,7 @@ import math from . import attachments, history, projects from ...core import db +from ...core.http import is_digits ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] @@ -43,7 +44,7 @@ def check_attachments(issues) -> None: where = f"{key}: attachments[{n}]" if not isinstance(a, dict): raise ValueError(f"{where} — объект вложения") - if not isinstance(a.get("id"), str) or not a["id"].isdigit(): + if not is_digits(a.get("id")): raise ValueError(f"{where}.id — строка из цифр") if not isinstance(a.get("filename"), str) or not a["filename"]: raise ValueError(f"{where}.filename — непустая строка") diff --git a/tests/test_api.py b/tests/test_api.py index a99f414..74cf6e5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1188,6 +1188,26 @@ def test_batches_post_fetch_too_large_413(hub): assert hub["client"].get("/batches")["batches"] == [] +@pytest.mark.parametrize("bad", ["²", "١٢", 7]) +def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad): + """#86 F.1: attachment_id из не-ASCII цифр или числом — 422, не 500; пакет не создан.""" + _seed_attachment(hub) + with pytest.raises(HubError) as e: + hub["client"].post("/batches", {"title": "x", "commands": [ + {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]}) + assert e.value.status == 422 + assert hub["client"].get("/batches")["batches"] == [] + + +@pytest.mark.parametrize("bad_id", ["²", 7]) +def test_agent_state_attachment_id_ascii_only_422(hub, bad_id): + """#86 F.1: id вложения в состоянии агента — только строка из ASCII-цифр (ascii digit).""" + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": [{"key": "Y-1", "summary": "Игрек", "labels": [], + "attachments": [{"id": bad_id, "filename": "a"}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + + def test_command_retry_fetch_too_large_413(hub): """M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large.""" _seed_attachment(hub) diff --git a/tests/test_attachments.py b/tests/test_attachments.py index 8018dea..d6db28b 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -93,3 +93,9 @@ def test_state_failed_and_cancelled(conn, tmp_path): def test_get_missing(conn, tmp_path): assert attachments.get(conn, tmp_path, "999") is None + + +def test_newest_key_non_ascii_digit_goes_last_ascii(): + """#86 F.1: id «²» не бросает в int() и уходит в хвост, как нецифровой (ascii digit).""" + assert attachments._newest_key({"attachment_id": "²", "created_at": "t"}) == ("t", -1, "²") + assert attachments._newest_key({"attachment_id": "12", "created_at": "t"}) == ("t", 12, "12") diff --git a/tests/test_commands.py b/tests/test_commands.py index b9393e6..6eec392 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -378,3 +378,10 @@ def test_normalize_summary_description_goal_untouched(): out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": " Т ", "description": " d\n"}}]) assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n" + + +def test_is_digits_ascii_only(): + from sdlc_hub.core.http import is_digits + assert is_digits("123") is True + for bad in ("", "²", "١٢", "12a", 7, None): + assert is_digits(bad) is False diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 78c5558..3bf15fd 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -369,6 +369,7 @@ def test_attachment_moves_to_other_key(conn): @pytest.mark.parametrize("bad, field", [ ("x", "attachments"), (["x"], "attachments[0]"), ([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": "1a", "filename": "a"}], "attachments[0].id"), + ([{"id": "²", "filename": "a"}], "attachments[0].id"), ([{"id": "١٢", "filename": "a"}], "attachments[0].id"), ([{"id": "-5", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"), ([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"), ([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"), diff --git a/tests/test_queue.py b/tests/test_queue.py index 5f95a77..62bfaf2 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1287,6 +1287,12 @@ def test_request_fetch_agent_too_old(fconn, tmp_path): assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" +def test_request_fetch_agent_version_not_ascii_digits_ascii(fconn, tmp_path): + """#86 F.1: версия агента «²» — неизвестная, не блокирует и не бросает ValueError (ascii digit).""" + fconn.execute("UPDATE agent_status SET agent_version = '²' WHERE agent = 'jira'") + assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" + + def test_request_fetch_after_failed(fconn, tmp_path): root = tmp_path / "files" first = jira_fetch.request_fetch(fconn, root, "31337", "x") From f1dae5a8114efc9e966be279f653425873b06143 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:32:39 +0400 Subject: [PATCH 05/12] =?UTF-8?q?API:=20=D1=81=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D0=BD=D0=B8=D0=B5=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B5=D0=B2=D0=B5=D1=80=D0=BD=D0=BE=D0=B9=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D1=8B=20=E2=80=94=20422,=20=D0=B0=20=D0=BD=D0=B5?= =?UTF-8?q?=20500=20(#129,=20#42=20T2.1,=20#94=20T8.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mirror.check_issues проверяет форму issues до записи; /wiki/sync отвергает project не строку. Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/modules/jira/api.py | 1 + src/sdlc_hub/modules/jira/mirror.py | 9 +++++++++ src/sdlc_hub/modules/wiki/api.py | 2 ++ tests/test_api.py | 14 ++++++++++++++ tests/test_wiki_api.py | 9 +++++++++ 5 files changed, 35 insertions(+) diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index 7e372c5..be5bbeb 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -24,6 +24,7 @@ def h_agent_state(hub, conn, m, q, body): issues = body.get("issues") or [] boards = body.get("boards") try: + mirror.check_issues(issues) mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком mirror.check_attachments(issues) mirror.check_agile_fields(issues) diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 59f7b2a..1f238a8 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -32,6 +32,15 @@ def check_comments(issues) -> None: if c.get(f) is not None and not isinstance(c[f], str): raise ValueError(f"{where}.{f} — строка или null") +def check_issues(issues) -> None: + """Форма списка задач состояния: список объектов с непустым строковым key. Нарушение — ValueError.""" + if not isinstance(issues, list): + raise ValueError("issues — список задач") + for n, it in enumerate(issues): + if not isinstance(it, dict) or not isinstance(it.get("key"), str) or not it["key"]: + raise ValueError(f"issues[{n}] — объект задачи с key") + + def check_attachments(issues) -> None: """Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем.""" for it in issues: diff --git a/src/sdlc_hub/modules/wiki/api.py b/src/sdlc_hub/modules/wiki/api.py index aa7e9a7..d9eac34 100644 --- a/src/sdlc_hub/modules/wiki/api.py +++ b/src/sdlc_hub/modules/wiki/api.py @@ -94,6 +94,8 @@ def h_search(hub, conn, m, q, body): def h_sync(hub, conn, m, q, body): + if body.get("project") is not None and not isinstance(body["project"], str): + raise ApiError(422, "bad_request", "project — строка") q = dict(q, project=body.get("project")) author = body.get("author") or "api" if not isinstance(author, str): diff --git a/tests/test_api.py b/tests/test_api.py index 74cf6e5..0338d96 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1299,3 +1299,17 @@ def test_rank_before_trimmed_on_claim(hub): {"type": "ensure_rank", "params": {"key": "X-1", "before": " X-2 "}}]}) cmd = hub["agent"].post("/agent/claim", {})["command"] assert cmd["type"] == "ensure_rank" and cmd["params"]["before"] == "X-2" + + +@pytest.mark.parametrize("issues, detail", [ + (["X-1"], "issues[0] — объект задачи с key"), + ([{"summary": "без ключа"}], "issues[0] — объект задачи с key"), + ([{"key": 5}], "issues[0] — объект задачи с key"), + ([{"key": ""}], "issues[0] — объект задачи с key"), + ({"key": "X-1"}, "issues — список задач"), + ("X-1", "issues — список задач"), +]) +def test_agent_state_bad_issue_shape_422(hub, issues, detail): + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": issues, "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail diff --git a/tests/test_wiki_api.py b/tests/test_wiki_api.py index 055061d..743ac74 100644 --- a/tests/test_wiki_api.py +++ b/tests/test_wiki_api.py @@ -228,3 +228,12 @@ def test_page_attachment_failed_error_visible(api): c.close() att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0] assert att["state"] == "failed" and att["error"] == "Eva: 404" + + +@pytest.mark.parametrize("project", [5, ["AWPCA"], {"a": 1}, True]) +def test_sync_project_not_string_422(api, project): + first_sync(api) + before = api.rows("SELECT id FROM batch") + e = err(api.client.post, "/wiki/sync", {"project": project}) + assert e.status == 422 and e.error == "bad_request" and e.detail == "project — строка" + assert api.rows("SELECT id FROM batch") == before # пакета не создано From 75b155935493b06c1f7a744a2eba99f3b0487102 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:41:20 +0400 Subject: [PATCH 06/12] =?UTF-8?q?=D0=A5=D1=80=D0=B0=D0=BD=D0=B8=D0=BB?= =?UTF-8?q?=D0=B8=D1=89=D0=B5:=20=D0=BD=D0=B5=D1=87=D0=B8=D1=82=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=D1=8B=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=20=E2=80=94?= =?UTF-8?q?=20=C2=AB=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=C2=BB=20=D1=81=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC;=20=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=BE=D0=B9=20SDLC=5FHUB=5FHOME=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8E=20(#129,=20#49=20M-3,=20#102=20T3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/files.py | 20 +++++++++++++++++--- src/sdlc_hub/core/paths.py | 2 +- tests/test_api.py | 13 +++++++++++++ tests/test_files.py | 28 ++++++++++++++++++++++++++++ tests/test_layout.py | 7 +++++++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/sdlc_hub/core/files.py b/src/sdlc_hub/core/files.py index a6447fc..baf37d7 100644 --- a/src/sdlc_hub/core/files.py +++ b/src/sdlc_hub/core/files.py @@ -3,8 +3,10 @@ каталог передаётся параметром (Hub.files_dir, решение 10).""" import hashlib import os +import logging import re import shutil +import stat as stat_mod import tempfile import time from pathlib import Path @@ -12,6 +14,8 @@ from pathlib import Path from . import registry from .paths import secure_dir +log = logging.getLogger(__name__) + MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) SHA_RE = re.compile(r"[0-9a-f]{64}") @@ -41,22 +45,32 @@ def exists(root, sha) -> bool: def size(root, sha) -> int | None: - """Размер файла в байтах; None — файла нет или sha негодный.""" + """Размер файла в байтах; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный.""" if not is_sha(sha): return None + target = path(root, sha) try: - return path(root, sha).stat().st_size + st = target.stat() except FileNotFoundError: return None + except OSError as e: + log.warning("файл хранилища %s не читается: %s", target, e) + return None + return st.st_size if stat_mod.S_ISREG(st.st_mode) else None def read(root, sha) -> bytes | None: + """Содержимое файла; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный.""" if not is_sha(sha): return None + target = path(root, sha) try: - return path(root, sha).read_bytes() + return target.read_bytes() except FileNotFoundError: return None + except OSError as e: + log.warning("файл хранилища %s не читается: %s", target, e) + return None def _intact(target, sha) -> bool: diff --git a/src/sdlc_hub/core/paths.py b/src/sdlc_hub/core/paths.py index 59fb68f..b8abc7d 100644 --- a/src/sdlc_hub/core/paths.py +++ b/src/sdlc_hub/core/paths.py @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) def home() -> Path: - return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub"))) + return Path(os.environ.get("SDLC_HUB_HOME") or str(Path.home() / ".local" / "share" / "sdlc-hub")) def legacy_home() -> Path: diff --git a/tests/test_api.py b/tests/test_api.py index 0338d96..db38a52 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -807,6 +807,19 @@ def test_attachment_flow_over_api(hub): assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} +def test_claim_with_directory_instead_of_file_has_size_none(hub): + """Файл вложения заменён каталогом: claim отвечает 200, size — None (#129, #49 M-3).""" + c, a = hub["client"], hub["agent"] + sha = c.post("/files", {"content": _b64(b"abc")})["file"] + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": sha, "filename": "a.xlsx"}}]}) + target = hub["hub"].files_dir / sha + target.unlink() + target.mkdir() + cmd = a.post("/agent/claim", {})["command"] + assert cmd["params"]["size"] is None + + def test_issues_hide_closed(hub): c = hub["client"] hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []}, diff --git a/tests/test_files.py b/tests/test_files.py index 07f536f..6282161 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -189,3 +189,31 @@ def test_sweep_downloads(tmp_path): assert files.sweep_downloads(root) == ["31337"] assert not old.exists() and fresh.exists() and (root / "loose.txt").exists() assert files.sweep_downloads(tmp_path / "нет") == [] + + +@pytest.mark.parametrize("func,attr", [(files.size, "stat"), (files.read, "read_bytes")]) +def test_unreadable_file_is_none_with_warning(root, monkeypatch, caplog, func, attr): + from pathlib import Path + sha = files.put(root, b"abc")[0] + + def denied(self, *args, **kwargs): + raise PermissionError(13, "Permission denied") + monkeypatch.setattr(Path, attr, denied) + with caplog.at_level("WARNING"): + assert func(root, sha) is None + assert sha in caplog.text and "не читается" in caplog.text + + +@pytest.mark.parametrize("func", [files.size, files.read]) +def test_directory_instead_of_file_is_none(root, caplog, func): + (root / ABC).mkdir(parents=True) + with caplog.at_level("WARNING"): + assert func(root, ABC) is None + + +@pytest.mark.parametrize("func", [files.size, files.read]) +def test_missing_file_is_none_without_log(root, caplog, func): + root.mkdir() + with caplog.at_level("WARNING"): + assert func(root, ABC) is None + assert caplog.text == "" diff --git a/tests/test_layout.py b/tests/test_layout.py index 79131d5..944f161 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -34,3 +34,10 @@ def test_env_home(monkeypatch, tmp_path): from sdlc_hub.core import paths monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) assert paths.home() == tmp_path / "x" + + +def test_empty_env_home_is_default(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.setenv("SDLC_HUB_HOME", "") + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub" From 151a5e6175ee76acdc2c8c42ec15ab5c62905266 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:42:05 +0400 Subject: [PATCH 07/12] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D1=80?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=20R-7=20(size=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20=E2=80=94=20None),=20des?= =?UTF-8?q?ign.md=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-backend-fixes/design.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md index 9d9412a..92b3e29 100644 --- a/openspec/changes/hub-backend-fixes/design.md +++ b/openspec/changes/hub-backend-fixes/design.md @@ -75,3 +75,7 @@ - R-6 (design #10): `base_jql` и `board_id` удаляются через механизм устаревших ключей, без новой версии схемы. Это тот же путь, что у `status_labels`, и при каждом старте ключи, вставленные старым процессом, убираются снова. Цена ошибки: нет. +- R-7 (design #7, 26.10.2026): `files.size` для каталога на месте файла возвращает `None` (проверка `S_ISREG`), а не размер + каталога. План предписывал только `except OSError`, но `Path.stat()` на каталоге не бросает исключение, и claim отдал бы + агенту размер каталога. Решение согласовано с `files.exists` (`is_file`). Цена ошибки: нет, поведение совпадает с целью + design #7 («файла нет»); `read` для каталога пишет предупреждение, `size` молчит (T4.1). From 5dc96dd43cfc5496c274934c6c67666f937097db Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 02:48:21 +0400 Subject: [PATCH 08/12] =?UTF-8?q?=D0=9F=D1=83=D0=B1=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?:=20=D0=BF=D1=80=D0=B5=D1=80=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B0=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=B0=D0=B2=D0=BB=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D0=BE=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=84=D0=B5=D1=81=D1=82=D1=83=20(#129,=20#39?= =?UTF-8?q?=20T2.2,=20M-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/release.py | 36 ++++++++++++++ tests/test_release.py | 96 ++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/src/sdlc_hub/core/release.py b/src/sdlc_hub/core/release.py index 10d413a..ba4b841 100644 --- a/src/sdlc_hub/core/release.py +++ b/src/sdlc_hub/core/release.py @@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict: raise PublishError("файл не в UTF-8") from e version = parse_version(text) rd = Path(release_dir) + _recover(rd) prev = current(rd) if prev and version <= prev["version"]: raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") @@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict: return manifest +def _sha256(path: Path) -> str | None: + try: + return hashlib.sha256(path.read_bytes()).hexdigest() + except FileNotFoundError: + return None + + +def _recover(rd: Path) -> None: + """Привести release/ к опубликованной версии после прерванной публикации (design hub-backend-fixes, решение 9). + Сверка с manifest.json по sha256: agent.py.prev совпадает, а agent.py нет или не совпадает — .prev возвращается на + место; agent.py совпадает — лишний .prev удаляется; иначе .prev не трогается, только предупреждение. Сбой + (OSError) — предупреждение в лог, публикация продолжается.""" + agent, prev_file = rd / "agent.py", rd / "agent.py.prev" + try: + if not prev_file.exists(): + return + try: + m = json.loads((rd / "manifest.json").read_text(encoding="utf-8")) + except (OSError, ValueError): + m = None + sha = m.get("sha256") if _manifest_problem(m) is None else None + if sha is None: + log.warning("release: найден %s, но манифеста нет или он негоден — не трогаем", prev_file) + elif _sha256(agent) == sha: + prev_file.unlink() + log.warning("release: лишний %s удалён: agent.py сходится с манифестом", prev_file) + elif _sha256(prev_file) == sha: + os.replace(prev_file, agent) + log.warning("release: прерванная публикация: %s возвращён на место agent.py", prev_file) + else: + log.warning("release: найден %s, не сходится с манифестом — не трогаем", prev_file) + except OSError as e: + log.warning("release: восстановление после прерванной публикации не удалось: %s", e) + + def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None: """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем. diff --git a/tests/test_release.py b/tests/test_release.py index d98abe4..4856311 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -298,3 +298,99 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path, assert release.current(bad) is None assert release.current(none) is None assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 + + +# --- пакет C: восстановление прерванной публикации (design hub-backend-fixes, решение 9; #39 T2.2, M-6) ---------- + +def _no_space(monkeypatch): + def no_space(fd): + raise OSError(28, "No space left on device") + + monkeypatch.setattr(release.os, "fsync", no_space) + + +def test_recover_missing_agent_restores_prev(tmp_path, monkeypatch, caplog): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + os.replace(rd / "agent.py", rd / "agent.py.prev") # процесс упал между переименованием и записью + _no_space(monkeypatch) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert (rd / "agent.py").read_bytes() == v5 + assert not (rd / "agent.py.prev").exists() + assert release.load(rd)["version"] == 5 + assert "agent.py.prev" in caplog.text + + +def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + (rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode()) + _no_space(monkeypatch) + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert (rd / "agent.py").read_bytes() == v5 + assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5 + assert release.load(rd)["version"] == 5 + + +def test_recover_success_after_crash(tmp_path): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + (rd / "agent.py").write_bytes("AGENT_VERSION = 6\n# обрыв".encode()) + v6 = agent_file(tmp_path, 6, name="new.py") + release.publish(rd, v6) + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert (rd / "agent.py").read_bytes() == v6.read_bytes() + assert release.load(rd)["version"] == 6 + + +def test_recover_leftover_prev_removed(tmp_path, monkeypatch): + """agent.py совпадает с манифестом, рядом лишний .prev: _recover удаляет его, даже если публикация потом падает + до переименования agent.py в .prev.""" + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + _fail_replace_into(monkeypatch, "agent.py.prev") + with pytest.raises(release.PublishError, match="не удалось записать публикацию"): + release.publish(rd, agent_file(tmp_path, 6, name="new.py")) + monkeypatch.undo() + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] + assert release.load(rd)["version"] == 5 + + +def test_recover_unknown_state_untouched(tmp_path, caplog): + rd = tmp_path / "release" + rd.mkdir() + (rd / "agent.py.prev").write_bytes(b"AGENT_VERSION = 3\n") + release._recover(rd) + assert _listing(rd) == {"agent.py.prev": b"AGENT_VERSION = 3\n"} + assert "agent.py.prev" in caplog.text + assert release.publish(rd, agent_file(tmp_path, 1))["version"] == 1 # первая публикация проходит + assert release.load(rd)["version"] == 1 + + +def test_recover_oserror_does_not_stop_publish(tmp_path, monkeypatch, caplog): + rd = tmp_path / "release" + v5 = agent_file(tmp_path, 5).read_bytes() + release.publish(rd, agent_file(tmp_path, 5)) + (rd / "agent.py.prev").write_bytes(v5) + real = Path.unlink + + def fake(self, *a, **kw): + if self.name == "agent.py.prev" and not getattr(fake, "done", False): + fake.done = True + raise OSError(13, "Permission denied") + return real(self, *a, **kw) + + monkeypatch.setattr(Path, "unlink", fake) + assert release.publish(rd, agent_file(tmp_path, 6, name="new.py"))["version"] == 6 + assert "Permission denied" in caplog.text From fd13770427052fd8ad33382b68d836843ec6c171 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 03:02:11 +0400 Subject: [PATCH 09/12] =?UTF-8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8:=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20v3=20bas?= =?UTF-8?q?e=5Fjql=20=D0=B8=20board=5Fid=20=D1=83=D0=B4=D0=B0=D0=BB=D1=8F?= =?UTF-8?q?=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B8=20=D1=85=D0=B0=D0=B1=D0=B0?= =?UTF-8?q?=20(#129,=20#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OBSOLETE расширен ключами base_jql и board_id: ensure_defaults убирает их при каждом открытии, миграция v7 -> v8 тоже. settings_version не растёт. PUT /settings для этих ключей по-прежнему отвечает 422 с пояснением. Тест миграции v3 обновлён: после миграции ключей в настройках нет (проекты уже созданы из них). Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/modules/jira/settings.py | 4 +-- tests/test_db.py | 3 +- tests/test_settings.py | 45 +++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py index 127390c..e6610ed 100644 --- a/src/sdlc_hub/modules/jira/settings.py +++ b/src/sdlc_hub/modules/jira/settings.py @@ -24,9 +24,9 @@ DEFAULTS = { "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } -# Ключи прежних версий (fact-status-removal): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом +# Ключи прежних версий (fact-status-removal; base_jql и board_id — v3, #129): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом # (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями -OBSOLETE = ("status_labels", "transitions_map") +OBSOLETE = ("status_labels", "transitions_map", "base_jql", "board_id") # Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", diff --git a/tests/test_db.py b/tests/test_db.py index 4b86003..3884527 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -299,8 +299,7 @@ def test_migrates_v3_creates_awpca(tmp_path): assert {k: v for k, v in api.project_rows(conn)[0].items() if not k.startswith(("wiki_", "jira_"))} | { "created_at": None, "issues_count": None} == { "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} - assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте - assert settings.get(conn, "board_id") == 4216 + assert settings.get(conn, "base_jql") is None and settings.get(conn, "board_id") is None # ключи v3 убраны (#129) assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")} assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем diff --git a/tests/test_settings.py b/tests/test_settings.py index 50c6f20..438c6ac 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -138,3 +138,48 @@ def test_sprint_types_pacing(conn): assert jira_settings.pacing(conn)["ensure_rank"] == 30 settings.validate("pacing", {"ensure_rank": 10}) assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90 + + +def test_v3_keys_removed_on_open(conn): + v0 = settings.get(conn, "settings_version") + conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')") + settings.ensure_defaults(conn) + stored = {r["key"] for r in conn.execute("SELECT key FROM setting")} + assert {"board_id", "base_jql"}.isdisjoint(stored) + assert settings.get(conn, "settings_version") == v0 # версия настроек не растёт + with pytest.raises(ValueError, match="доска Jira — свойство проекта"): + settings.validate("board_id", 4216) + with pytest.raises(ValueError, match="собирается из проектов"): + settings.validate("base_jql", "project = AURORA") + + +def test_v3_keys_removed_on_hub_start_api(tmp_path): + import threading + + from sdlc_hub.core import api, db + from sdlc_hub.core.client import HubClient, HubError + path = tmp_path / "hub.sqlite" + c = db.connect(path) + settings.ensure_defaults(c) + c.execute("INSERT OR REPLACE INTO setting(key, value) VALUES ('board_id', '4216'), ('base_jql', '\"project = AURORA\"')") + c.commit() + c.close() + h = api.Hub(path, backups_dir=tmp_path / "backups") + conn = h.conn() + token = settings.tokens(conn)["client"] + conn.close() + srv = api.make_server(h, "127.0.0.1", 0) + th = threading.Thread(target=srv.serve_forever, daemon=True) + th.start() + try: + client = HubClient(f"http://127.0.0.1:{srv.server_port}", token) + got = client.get("/settings") + assert "board_id" not in got and got["base_jql"] != "project = AURORA" # base_jql вычисляется (без проектов — None) + for key in ("board_id", "base_jql"): + with pytest.raises(HubError) as e: + client.put("/settings", {key: 1}) + assert e.value.status == 422 and e.value.detail + finally: + srv.shutdown() + srv.server_close() + th.join(5) From 98faabad67b41d16719f1754cd05c315d22fee2e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 03:10:20 +0400 Subject: [PATCH 10/12] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D1=80?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B8=20R-8,=20R-9,=20R-10=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=B8=D1=82=D0=BE=D0=B3=D0=B0=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-backend-fixes/design.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openspec/changes/hub-backend-fixes/design.md b/openspec/changes/hub-backend-fixes/design.md index 92b3e29..4cd14d0 100644 --- a/openspec/changes/hub-backend-fixes/design.md +++ b/openspec/changes/hub-backend-fixes/design.md @@ -79,3 +79,15 @@ каталога. План предписывал только `except OSError`, но `Path.stat()` на каталоге не бросает исключение, и claim отдал бы агенту размер каталога. Решение согласовано с `files.exists` (`is_file`). Цена ошибки: нет, поведение совпадает с целью design #7 («файла нет»); `read` для каталога пишет предупреждение, `size` молчит (T4.1). +- R-8 (design #1, 26.10.2026; финальное ревью I-1): необязательный параметр `ensure_fields` (поля `FIELD_PARAMS`: `assignee`, + `epic_key`, `priority`, даты, оценки), заданный строкой из одних пробелов, отвергается с 422 «<параметр> — из одних + пробелов; для очистки — "" или null». Иначе обрезка превращала бы его в `""`, а агент читает `""` как «очистить поле в + Jira». Для `before`/`after`/`move_open_to` пробелы по-прежнему означают «не задано». Цена ошибки: ввод из одних пробелов + отвергается хабом, а не Jira; очистка полей работает как раньше через `""` или `null`. +- R-9 (design #7, уточняет R-7; финальное ревью I-3): `files.size` открывает файл и берёт размер через `fstat`, `_intact` + ловит `OSError`. Причина: `Path.stat()` не требует права на чтение, и `chmod 0` давал настоящий размер при `read` → `None`; + повторная загрузка того же файла давала 500. Теперь `size` для нечитаемого файла и каталога — `None` с предупреждением, + `put` перезаписывает нечитаемый файл. Цена ошибки: лишнее открытие файла при claim. +- R-10 (design #10; финальное ревью M-3, T6.2): на базе v7 с ключами v3 `migrate_v7` удаляет `base_jql` и `board_id` и + поднимает `settings_version`; неразобранный `base_jql` теряется вместе с ключом (значение остаётся в предупреждении + `migrate_v3` и в `backups/schema-v3`). Цена ошибки: потеря неразобранного значения, которое нигде не читается. From e20977ef4956ed557848bda54a383cc896ead818 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 03:16:39 +0400 Subject: [PATCH 11/12] =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8F=20ensure=5Ffields,=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20?= =?UTF-8?q?=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2,=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20issues=20(#129?= =?UTF-8?q?,=20I-1,=20I-2,=20I-3,=20M-2,=20T2.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 (R-8): ensure_fields с полем из одних пробелов даёт 422, очистка только "" или null. I-2: agent_view не падает на нецифровой версии агента (GET /agent, /agents). I-3 (R-9): files.size через fstat у открытого файла, _intact ловит OSError, put переписывает нечитаемый файл. M-2: /agent/state отвергает пустые не-списки в issues. T2.1, T3.1, T4.1-T4.3, T5.2, T6.1, T1.5: тесты и мелкие правки. Co-Authored-By: Claude Opus 5.5 --- src/sdlc_hub/core/files.py | 7 ++++--- src/sdlc_hub/core/queue.py | 4 +++- src/sdlc_hub/modules/jira/api.py | 3 ++- src/sdlc_hub/modules/jira/commands.py | 5 +++++ src/sdlc_hub/modules/jira/mcp.py | 3 ++- src/sdlc_hub/modules/jira/mirror.py | 1 + tests/test_api.py | 23 +++++++++++++++++++++++ tests/test_commands.py | 20 ++++++++++++++++++++ tests/test_files.py | 25 +++++++++++++++---------- tests/test_queue.py | 8 ++++++++ tests/test_release.py | 3 ++- tests/test_settings.py | 2 +- 12 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/sdlc_hub/core/files.py b/src/sdlc_hub/core/files.py index baf37d7..3274c5a 100644 --- a/src/sdlc_hub/core/files.py +++ b/src/sdlc_hub/core/files.py @@ -2,8 +2,8 @@ команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: каталог передаётся параметром (Hub.files_dir, решение 10).""" import hashlib -import os import logging +import os import re import shutil import stat as stat_mod @@ -50,7 +50,8 @@ def size(root, sha) -> int | None: return None target = path(root, sha) try: - st = target.stat() + with open(target, "rb") as f: # fstat у дескриптора: Path.stat() не требует права на чтение (R-9) + st = os.fstat(f.fileno()) except FileNotFoundError: return None except OSError as e: @@ -77,7 +78,7 @@ def _intact(target, sha) -> bool: """Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью).""" try: return hashlib.sha256(target.read_bytes()).hexdigest() == sha - except FileNotFoundError: + except OSError: # нет файла, нечитаем или каталог — не целая копия, put перепишет return False diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py index 44fe504..69eca47 100644 --- a/src/sdlc_hub/core/queue.py +++ b/src/sdlc_hub/core/queue.py @@ -10,6 +10,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import db, registry, settings from .errors import CommandError +from .http import is_digits log = logging.getLogger(__name__) @@ -427,7 +428,8 @@ def agent_view(conn, agent: str) -> dict: d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES) m = registry.module(agent) min_version = int(settings.get(conn, m.agent_min_version_key) or 0) if m and m.agent_min_version_key else 0 - d["warning"] = "обновите агента" if d["agent_version"] and int(d["agent_version"]) < min_version else None + known = is_digits(str(d["agent_version"])) if d["agent_version"] else False # «²», «10-dev» — версия неизвестна + d["warning"] = "обновите агента" if known and int(d["agent_version"]) < min_version else None d["rate_limited_24h"] = conn.execute( f"SELECT COALESCE(SUM(rate_limited), 0) AS n FROM command WHERE finished_at >= ? AND type IN ({_in(types)})", ((now - timedelta(days=1)).isoformat(), *types)).fetchone()["n"] diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index be5bbeb..69a3db9 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -21,7 +21,8 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") def h_agent_state(hub, conn, m, q, body): - issues = body.get("issues") or [] + issues = body.get("issues") + issues = [] if issues is None else issues # {} "" 0 false — не список: check_issues отвергнет boards = body.get("boards") try: mirror.check_issues(issues) diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index f34daa8..0cd3e2e 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -234,6 +234,11 @@ def validate(commands) -> list[dict]: bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] if bad: raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad)) + if ctype == "ensure_fields": # « » стало бы «» = «очистить поле»; очистка — только явная (R-8) + for name in FIELD_PARAMS: + v = params.get(name) + if name not in TEXT_PARAMS and isinstance(v, str) and v and not v.strip(): + raise CommandError(f'команда {i} ({ctype}): {name} — из одних пробелов; для очистки — "" или null') params = normalize(ctype, params) missing = [f for f in spec["required"] if params.get(f) in (None, "", [])] if missing: diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index 70efa36..857e09d 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -351,7 +351,8 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: prefix = "" if not found: want = unicodedata.normalize("NFC", attachment.strip()) - found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] # хаб отдаёт вложения новыми сверху + # хаб отдаёт вложения новыми сверху + found = [a for a in atts if unicodedata.normalize("NFC", a["filename"]) == want] if len(found) > 1: prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n" if not found: diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index 1f238a8..59c2656 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -32,6 +32,7 @@ def check_comments(issues) -> None: if c.get(f) is not None and not isinstance(c[f], str): raise ValueError(f"{where}.{f} — строка или null") + def check_issues(issues) -> None: """Форма списка задач состояния: список объектов с непустым строковым key. Нарушение — ValueError.""" if not isinstance(issues, list): diff --git a/tests/test_api.py b/tests/test_api.py index db38a52..88d43b7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1209,6 +1209,8 @@ def test_batches_post_fetch_attachment_id_not_ascii_digits_422(hub, bad): hub["client"].post("/batches", {"title": "x", "commands": [ {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": bad}}]}) assert e.value.status == 422 + want = "attachment_id — строка" if bad == 7 else "attachment_id — цифры id вложения из issue" + assert want in e.value.detail assert hub["client"].get("/batches")["batches"] == [] @@ -1326,3 +1328,24 @@ def test_agent_state_bad_issue_shape_422(hub, issues, detail): with pytest.raises(HubError) as e: hub["agent"].post("/agent/state", {"issues": issues, "links": []}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail + + +@pytest.mark.parametrize("bad", [{}, "", 0, False]) +def test_agent_state_falsy_non_list_issues_422(hub, bad): + """M-2: пустые не-списки не маскируются под «нет задач».""" + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/state", {"issues": bad, "links": []}) + assert e.value.status == 422 and "issues — список задач" in e.value.detail + + +@pytest.mark.parametrize("body", [{"links": []}, {"issues": None, "links": []}]) +def test_agent_state_missing_or_null_issues_ok(hub, body): + hub["agent"].post("/agent/state", body) + + +@pytest.mark.parametrize("version", ["²", "10-dev"]) +def test_agent_endpoints_survive_non_digit_agent_version(hub, version): + """I-2: GET /agent и /agents отвечают 200 при нецифровой версии агента.""" + hub["agent"].post("/agent/claim", {"agent_version": version, "settings_version": 1}) + assert hub["client"].get("/agent")["agent_version"] == version + assert any(a["agent_version"] == version for a in hub["client"].get("/agents")["agents"]) diff --git a/tests/test_commands.py b/tests/test_commands.py index 6eec392..c4ef8ef 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -385,3 +385,23 @@ def test_is_digits_ascii_only(): assert is_digits("123") is True for bad in ("", "²", "١٢", "12a", 7, None): assert is_digits(bad) is False + + +@pytest.mark.parametrize("field", ["assignee", "epic_key", "priority", "duedate", "planned_start"]) +def test_ensure_fields_whitespace_only_rejected(field): + """I-1 (R-8): « » после обрезки стало бы «» («очистить поле») — отвергается.""" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", field: " "}}]) + assert f'{field} — из одних пробелов; для очистки — "" или null' in str(e.value) + assert "команда 1 (ensure_fields)" in str(e.value) + + +@pytest.mark.parametrize("value", ["", None]) +def test_ensure_fields_explicit_clear_accepted(value): + cmds = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "assignee": value}}]) + assert cmds[0]["params"]["assignee"] == value + + +def test_rank_before_after_whitespace_still_means_not_given(): + cmds = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": "A-2", "after": " "}}]) + assert cmds[0]["params"]["after"] == "" diff --git a/tests/test_files.py b/tests/test_files.py index 6282161..730fb2b 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -191,17 +191,21 @@ def test_sweep_downloads(tmp_path): assert files.sweep_downloads(tmp_path / "нет") == [] -@pytest.mark.parametrize("func,attr", [(files.size, "stat"), (files.read, "read_bytes")]) -def test_unreadable_file_is_none_with_warning(root, monkeypatch, caplog, func, attr): - from pathlib import Path +@pytest.mark.skipif(os.geteuid() == 0, reason="root читает любой файл") +def test_unreadable_file_is_none_with_warning_and_put_rewrites(root, caplog): + """I-3 (R-9): настоящий chmod 0 — size и read дают None с предупреждением, put пишет файл заново.""" sha = files.put(root, b"abc")[0] - - def denied(self, *args, **kwargs): - raise PermissionError(13, "Permission denied") - monkeypatch.setattr(Path, attr, denied) - with caplog.at_level("WARNING"): - assert func(root, sha) is None - assert sha in caplog.text and "не читается" in caplog.text + target = root / sha + os.chmod(target, 0) + try: + with caplog.at_level("WARNING"): + assert files.size(root, sha) is None + assert files.read(root, sha) is None + assert str(target) in caplog.text and "не читается" in caplog.text + assert files.put(root, b"abc") == (sha, 3) + assert target.read_bytes() == b"abc" + finally: + os.chmod(target, 0o600) @pytest.mark.parametrize("func", [files.size, files.read]) @@ -209,6 +213,7 @@ def test_directory_instead_of_file_is_none(root, caplog, func): (root / ABC).mkdir(parents=True) with caplog.at_level("WARNING"): assert func(root, ABC) is None + assert str(root / ABC) in caplog.text and "не читается" in caplog.text @pytest.mark.parametrize("func", [files.size, files.read]) diff --git a/tests/test_queue.py b/tests/test_queue.py index 62bfaf2..a6ce663 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1373,3 +1373,11 @@ def test_retry_sprint_meta_gets_jql_tail(conn, add_project): cmds = queue.batch_view(conn, r["id"])["commands"] assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"] assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} + + +@pytest.mark.parametrize("version", ["²", "10-dev", "١٢"]) +def test_agent_view_non_digit_version_is_unknown(conn, version): + """I-2: нецифровая версия агента — неизвестная: agent_view не бросает ValueError и не предупреждает.""" + queue.claim(conn, "jira", agent_version=version, settings_version=1) + v = queue.agent_view(conn, "jira") + assert v["agent_version"] == version and v["warning"] is None diff --git a/tests/test_release.py b/tests/test_release.py index 4856311..362e577 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -335,7 +335,8 @@ def test_recover_partial_agent_keeps_good_prev(tmp_path, monkeypatch): release.publish(rd, agent_file(tmp_path, 6, name="new.py")) monkeypatch.undo() assert (rd / "agent.py").read_bytes() == v5 - assert not (rd / "agent.py.prev").exists() or (rd / "agent.py.prev").read_bytes() == v5 + assert not (rd / "agent.py.prev").exists() # _undo вернул .prev на место agent.py + assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"] assert release.load(rd)["version"] == 5 diff --git a/tests/test_settings.py b/tests/test_settings.py index 438c6ac..0f5aa04 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -174,7 +174,7 @@ def test_v3_keys_removed_on_hub_start_api(tmp_path): try: client = HubClient(f"http://127.0.0.1:{srv.server_port}", token) got = client.get("/settings") - assert "board_id" not in got and got["base_jql"] != "project = AURORA" # base_jql вычисляется (без проектов — None) + assert "board_id" not in got and got["base_jql"] is None # base_jql вычисляется (без проектов — None) for key in ("board_id", "base_jql"): with pytest.raises(HubError) as e: client.put("/settings", {key: 1}) From 1cb56be0108eb4ec7ff194e864d47f02c9444dc3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 03:23:19 +0400 Subject: [PATCH 12/12] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0:=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=20ensure=5Ffields=20=D0=B8=D0=B7=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D0=BE=D0=B2=20=E2=80=94=20422=20?= =?UTF-8?q?(R-8,=20#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-backend-fixes/specs/commands/spec.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openspec/changes/hub-backend-fixes/specs/commands/spec.md b/openspec/changes/hub-backend-fixes/specs/commands/spec.md index da7eae9..a036fab 100644 --- a/openspec/changes/hub-backend-fixes/specs/commands/spec.md +++ b/openspec/changes/hub-backend-fixes/specs/commands/spec.md @@ -5,7 +5,9 @@ каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок: обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи `new:` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт -422 «<параметр> — без пустых элементов». Строковые параметры обрезаются по краям, кроме текстовых `summary`, +422 «<параметр> — без пустых элементов». Необязательное поле `ensure_fields` из одних пробелов (`priority`, `duedate`, +`planned_start`, `planned_end`, `epic_key`, `assignee`) даёт 422 «<параметр> — из одних пробелов; для очистки — "" или +null»: после обрезки оно стало бы пустой строкой, которую агент читает как «очистить поле в Jira» (R-8). Строковые параметры обрезаются по краям, кроме текстовых `summary`, `description`, `body` и `goal`, которые хранятся как заданы. Элементы списков `labels`, `labels_add`, `labels_remove` и `keys`, а также `target` у `ensure_status`, если это список, обрезаются поэлементно. Числа, логические значения и `null` не меняются. `filename` у `ensure_attachment` SHALL дополнительно приводиться к нормальной форме Unicode NFC. @@ -28,6 +30,10 @@ - **WHEN** `POST /batches` с `ensure_status {key: " ", target: "Готово"}` - **THEN** 422 «команда 1 (ensure_status): нет key; ожидаются key, target» +#### Scenario: Поле из пробелов не очищает Jira +- **WHEN** `POST /batches` с `ensure_fields {key: "A-1", assignee: " "}` +- **THEN** 422 «команда 1 (ensure_fields): assignee — из одних пробелов; для очистки — "" или null», пакет не создан + #### Scenario: Имя вложения в NFD - **WHEN** `ensure_attachment` с `filename` «отчёт.pdf» в NFD («е» + U+0308) - **THEN** сохранён `filename` «отчёт.pdf» в NFC; повторная постановка того же файла с именем в NFC даёт ту же строку