Бэкенд хаба: нормализация параметров, 422 вместо 500, файлы, публикация агента, уборка настроек (#129) #130
311
docs/superpowers/plans/2026-10-26-hub-backend-fixes.md
Normal file
311
docs/superpowers/plans/2026-10-26-hub-backend-fixes.md
Normal file
@ -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/<sha>`) → `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)"
|
||||
```
|
||||
@ -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` тоже обрезаются. Числа,
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
### Requirement: Нормализация параметров при постановке
|
||||
При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в
|
||||
каноническом виде, после всех проверок валидации. Строковые параметры обрезаются по краям, кроме текстовых `summary`,
|
||||
каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок:
|
||||
обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи
|
||||
`new:<n>` и проверки `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 даёт ту же строку
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user