Merge pull request 'Бэкенд хаба: нормализация параметров, 422 вместо 500, файлы, публикация агента, уборка настроек (#129)' (#130) from feat/129-hub-backend into main

This commit is contained in:
aep 2026-10-26 09:19:49 +03:00
commit 6f2cf1af03
34 changed files with 1142 additions and 24 deletions

View 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)"
```

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-26

View File

@ -0,0 +1,93 @@
## Context
Пакет C сверки follow-ups 25.10.2026 (sdlc-hub#129). Все правки — в Python-коде хаба, без смены схемы базы и контракта
с агентами. Владелец пропустил вопросы brainstorming («не сталкивался и не хочу»): решения ниже приняты агентом и
продублированы в «Decisions made without discussion».
## Decisions
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` тоже обрезаются. Числа,
логические значения и `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` (ссылка `#<id>`). В разборе дат `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`, и при каждом старте ключи, вставленные старым процессом, убираются снова.
Цена ошибки: нет.
- 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).
- 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`). Цена ошибки: потеря неразобранного значения, которое нигде не читается.

View File

@ -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.

View File

@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Нормализация параметров при постановке
При постановке пакета (`POST /batches`, MCP `batch_create`, повтор команды) хаб SHALL сохранять параметры команд в
каноническом виде. Нормализация SHALL выполняться сразу после проверки типов значений, до остальных проверок:
обязательный параметр, ставший пустым после обрезки, даёт 422 «нет <параметр>»; правила типов, временные ключи
`new:<n>` и проверки `ensure_attachment` работают по нормализованным значениям; элемент списка, ставший пустым, даёт
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.
Агент 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: Временный ключ с пробелами
- **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: Поле из пробелов не очищает 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 даёт ту же строку
`filename`
### Requirement: Идентификаторы из цифр — только ASCII
Хаб SHALL считать строку числовым идентификатором (`attachment_id` в `fetch_attachment`, `id` вложения в состоянии
агента, ссылка `#<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», пакет не создан

View File

@ -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 — свойство проекта»

View File

@ -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/<sha>` и
скачивание вложения отвечают 404, как для удалённого файла. В лог хаба SHALL писаться предупреждение с путём и
ошибкой. `FileNotFoundError` в лог не пишется.
#### Scenario: Нет прав на файл
- **WHEN** у файла `files/<sha>` сняты права на чтение, агент берёт `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`
не затёрт недописанным файлом

View File

@ -2,9 +2,11 @@
команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения: команда модуля (bug-and-attachments, hub-api «Хранилище файлов вложений»). Модуль не знает HTTP и окружения:
каталог передаётся параметром (Hub.files_dir, решение 10).""" каталог передаётся параметром (Hub.files_dir, решение 10)."""
import hashlib import hashlib
import logging
import os import os
import re import re
import shutil import shutil
import stat as stat_mod
import tempfile import tempfile
import time import time
from pathlib import Path from pathlib import Path
@ -12,6 +14,8 @@ from pathlib import Path
from . import registry from . import registry
from .paths import secure_dir from .paths import secure_dir
log = logging.getLogger(__name__)
MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1)
KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2) KEEP_DAYS = 30 # возраст по mtime, после которого файл без ссылок убирается (решение 2)
SHA_RE = re.compile(r"[0-9a-f]{64}") SHA_RE = re.compile(r"[0-9a-f]{64}")
@ -41,29 +45,40 @@ def exists(root, sha) -> bool:
def size(root, sha) -> int | None: def size(root, sha) -> int | None:
"""Размер файла в байтах; None — файла нет или sha негодный.""" """Размер файла в байтах; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
if not is_sha(sha): if not is_sha(sha):
return None return None
target = path(root, sha)
try: try:
return path(root, sha).stat().st_size with open(target, "rb") as f: # fstat у дескриптора: Path.stat() не требует права на чтение (R-9)
st = os.fstat(f.fileno())
except FileNotFoundError: except FileNotFoundError:
return None 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: def read(root, sha) -> bytes | None:
"""Содержимое файла; None — файла нет (в том числе на его месте каталог), он не читается или sha негодный."""
if not is_sha(sha): if not is_sha(sha):
return None return None
target = path(root, sha)
try: try:
return path(root, sha).read_bytes() return target.read_bytes()
except FileNotFoundError: except FileNotFoundError:
return None return None
except OSError as e:
log.warning("файл хранилища %s не читается: %s", target, e)
return None
def _intact(target, sha) -> bool: def _intact(target, sha) -> bool:
"""Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью).""" """Копия на диске совпадает по sha256: испорченную того же размера повторная загрузка переписывает (M-2 ревью)."""
try: try:
return hashlib.sha256(target.read_bytes()).hexdigest() == sha return hashlib.sha256(target.read_bytes()).hexdigest() == sha
except FileNotFoundError: except OSError: # нет файла, нечитаем или каталог — не целая копия, put перепишет
return False return False

View File

@ -30,6 +30,12 @@ def int_group(m, i=1) -> int:
return int(m.group(i)) 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: def int_param(q, name, default) -> int:
try: try:
return int(q.get(name, default)) return int(q.get(name, default))

View File

@ -7,7 +7,7 @@ log = logging.getLogger(__name__)
def home() -> Path: 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: def legacy_home() -> Path:

View File

@ -10,6 +10,7 @@ from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import db, registry, settings from . import db, registry, settings
from .errors import CommandError from .errors import CommandError
from .http import is_digits
log = logging.getLogger(__name__) 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) d["online"] = bool(d["last_seen_at"]) and now - db.parse(d["last_seen_at"]) < timedelta(minutes=ONLINE_MINUTES)
m = registry.module(agent) 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 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( 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)})", 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"] ((now - timedelta(days=1)).isoformat(), *types)).fetchone()["n"]

View File

@ -99,6 +99,7 @@ def publish(release_dir, source) -> dict:
raise PublishError("файл не в UTF-8") from e raise PublishError("файл не в UTF-8") from e
version = parse_version(text) version = parse_version(text)
rd = Path(release_dir) rd = Path(release_dir)
_recover(rd)
prev = current(rd) prev = current(rd)
if prev and version <= prev["version"]: if prev and version <= prev["version"]:
raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше") raise PublishError(f"опубликована v{prev['version']}, новая должна быть больше")
@ -123,6 +124,41 @@ def publish(release_dir, source) -> dict:
return manifest 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: def _undo(agent: Path, prev_file: Path, moved: bool, existed: bool) -> None:
"""Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было — """Вернуть release/ к прежней публикации после сбоя записи: agent.py.prev на место agent.py; если прежнего не было —
убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем. убрать записанный agent.py. Прежний agent.py, которого не сдвинули (сбой до переименования), не трогаем.

View File

@ -21,9 +21,11 @@ _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def h_agent_state(hub, conn, m, q, body): 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") boards = body.get("boards")
try: try:
mirror.check_issues(issues)
mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком
mirror.check_attachments(issues) mirror.check_attachments(issues)
mirror.check_agile_fields(issues) mirror.check_agile_fields(issues)

View File

@ -4,6 +4,7 @@
import re import re
from ...core import files from ...core import files
from ...core.http import is_digits
# lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки; # lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки;
# после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя # после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя
@ -40,7 +41,7 @@ def ref_names(text: str | None) -> list[str]:
def _newest_key(item: dict): def _newest_key(item: dict):
aid = str(item["attachment_id"]) 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]: def resolve(names: list[str], items: list[dict]) -> list[str]:

View File

@ -3,6 +3,7 @@
проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" проверка «результат уже виден в зеркале» для подтверждения по состоянию."""
import math import math
import re import re
import unicodedata
from datetime import date from datetime import date
from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) 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"} LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
BOOL_PARAMS = {"replace"} BOOL_PARAMS = {"replace"}
NUMBER_PARAMS = {"story_points"} NUMBER_PARAMS = {"story_points"}
TEXT_PARAMS = {"summary", "description", "body", "goal"} # содержательный текст: пробелы и отступы значимы
SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch) SPRINT_TYPES = ("ensure_sprint_meta", "ensure_sprint_state") # только в пакете проекта (check_batch)
DATE_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$") 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} — строка" 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: def _date(value) -> date | None:
"""Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None.""" """Строка ГГГГ-ММ-ДД, которая задаёт настоящую дату; иначе None."""
if not isinstance(value, str) or not DATE_RE.match(value): if not isinstance(value, str) or not DATE_RE.match(value):
@ -205,6 +234,18 @@ def validate(commands) -> list[dict]:
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e] bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
if bad: if bad:
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(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:
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) rule = _rule_error(ctype, params)
if rule: if rule:
raise CommandError(f"команда {i} ({ctype}): {rule}") raise CommandError(f"команда {i} ({ctype}): {rule}")

View File

@ -1,6 +1,7 @@
"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки.""" """Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки."""
from ...core import db, queue from ...core import db, queue
from ...core.errors import CommandError, HttpMapped from ...core.errors import CommandError, HttpMapped
from ...core.http import is_digits
from . import attachments, commands from . import attachments, commands
FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment 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"): if info["state"] in ("ready", "fetching"):
return info return info
v = queue.agent_version(conn, "jira") 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}") raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}")
cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}]) cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}])
hooks.check_setup(conn, cmds, files_dir) hooks.check_setup(conn, cmds, files_dir)

View File

@ -4,6 +4,7 @@ from datetime import datetime, timedelta, timezone
from . import projects from . import projects
from ...core import db, settings from ...core import db, settings
from ...core.http import is_digits
SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5) 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: if not isinstance(value, str) or not value:
return None return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value 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:] s = s[:-2] + ":" + s[-2:]
try: try:
d = datetime.fromisoformat(s) d = datetime.fromisoformat(s)

View File

@ -3,6 +3,7 @@ import json
import logging import logging
from ...core import files, projects, queue, settings from ...core import files, projects, queue, settings
from ...core.http import is_digits
from ...core.errors import CommandError from ...core.errors import CommandError
from ...core.modules import CommandHooks from ...core.modules import CommandHooks
from . import attachments, commands, history, mirror, refresh 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) # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
if c["type"] == "fetch_attachment": 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") raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue")
row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?", row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?",
(p["attachment_id"], p["key"])).fetchone() (p["attachment_id"], p["key"])).fetchone()

View File

@ -6,6 +6,7 @@ import base64
import json import json
import stat import stat
import time import time
import unicodedata
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@ -14,6 +15,7 @@ from mcp.server.fastmcp import FastMCP
from ...core import mcp as core from ...core import mcp as core
from ...core.client import HubError from ...core.client import HubError
from ...core.files import MAX_SIZE from ...core.files import MAX_SIZE
from ...core.http import is_digits
from .attachments import fmt_size from .attachments import fmt_size
from .commands import describe, is_temp from .commands import describe, is_temp
@ -45,7 +47,7 @@ def fmt_day(value) -> str:
text = value.strip() text = value.strip()
if text.endswith(("Z", "z")): if text.endswith(("Z", "z")):
text = text[:-1] + "+00:00" 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:] text = text[:-2] + ":" + text[-2:]
try: try:
at = datetime.fromisoformat(text) at = datetime.fromisoformat(text)
@ -217,7 +219,7 @@ DATE_LABELS = {"duedate": "срок", "planned_start": "плановое нач
def _day(value, text) -> str: 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 f"{value[8:10]}.{value[5:7]}"
return core.value(text) return core.value(text)
@ -345,10 +347,12 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
d = await asyncio.to_thread(client.get, f"/issues/{key}") d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or [] atts = d.get("attachments") or []
ref = attachment.strip().lstrip("#") 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 = "" prefix = ""
if not found: 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: if len(found) > 1:
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n" prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
if not found: if not found:
@ -478,7 +482,8 @@ def _attach_files(client, commands):
params = {k: v for k, v in cmd["params"].items() if k != "path"} params = {k: v for k, v in cmd["params"].items() if k != "path"}
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")}) up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
params["file"] = up["file"] 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}) out.append({**cmd, "params": params})
return out return out

View File

@ -5,6 +5,7 @@ import math
from . import attachments, history, projects from . import attachments, history, projects
from ...core import db from ...core import db
from ...core.http import is_digits
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
@ -31,6 +32,16 @@ def check_comments(issues) -> None:
if c.get(f) is not None and not isinstance(c[f], str): if c.get(f) is not None and not isinstance(c[f], str):
raise ValueError(f"{where}.{f} — строка или null") 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: def check_attachments(issues) -> None:
"""Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем.""" """Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем."""
for it in issues: for it in issues:
@ -43,7 +54,7 @@ def check_attachments(issues) -> None:
where = f"{key}: attachments[{n}]" where = f"{key}: attachments[{n}]"
if not isinstance(a, dict): if not isinstance(a, dict):
raise ValueError(f"{where} — объект вложения") 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 — строка из цифр") raise ValueError(f"{where}.id — строка из цифр")
if not isinstance(a.get("filename"), str) or not a["filename"]: if not isinstance(a.get("filename"), str) or not a["filename"]:
raise ValueError(f"{where}.filename — непустая строка") raise ValueError(f"{where}.filename — непустая строка")

View File

@ -24,9 +24,9 @@ DEFAULTS = {
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) "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) вставил бы их снова своими умолчаниями # (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями
OBSOLETE = ("status_labels", "transitions_map") OBSOLETE = ("status_labels", "transitions_map", "base_jql", "board_id")
# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро # Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",

View File

@ -94,6 +94,8 @@ def h_search(hub, conn, m, q, body):
def h_sync(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")) q = dict(q, project=body.get("project"))
author = body.get("author") or "api" author = body.get("author") or "api"
if not isinstance(author, str): if not isinstance(author, str):

View File

@ -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"} 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): def test_issues_hide_closed(hub):
c = hub["client"] c = hub["client"]
hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []}, hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "status": "В работе", "labels": []},
@ -1188,6 +1201,28 @@ def test_batches_post_fetch_too_large_413(hub):
assert hub["client"].get("/batches")["batches"] == [] 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
want = "attachment_id — строка" if bad == 7 else "attachment_id — цифры id вложения из issue"
assert want in e.value.detail
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): def test_command_retry_fetch_too_large_413(hub):
"""M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large.""" """M-2: повтор fetch_attachment, когда размер в зеркале вырос выше предела, — 413 too_large."""
_seed_attachment(hub) _seed_attachment(hub)
@ -1270,3 +1305,47 @@ def test_agent_state_empty_batch_with_boards(hub):
assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1 assert c.execute("SELECT COUNT(*) FROM jira_sprint").fetchone()[0] == 1
finally: finally:
c.close() 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"
@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
@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"])

View File

@ -93,3 +93,9 @@ def test_state_failed_and_cancelled(conn, tmp_path):
def test_get_missing(conn, tmp_path): def test_get_missing(conn, tmp_path):
assert attachments.get(conn, tmp_path, "999") is None 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")

View File

@ -1,4 +1,5 @@
import re import re
import unicodedata
import pytest 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"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_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"), ({**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", "rename_to": " "}}, "rename_to — непустое имя"),
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"), ({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
(state(state="open"), "state — active или closed"), (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("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": float("inf")}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError ({"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=" "), "move_open_to обязателен при state = closed"),
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), (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"), (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
@ -330,3 +331,77 @@ 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-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", 0), {})[0] is False
assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True 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"
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
@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"] == ""

View File

@ -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_"))} | { 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} == { "created_at": None, "issues_count": None} == {
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "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, "base_jql") is None and settings.get(conn, "board_id") is None # ключи v3 убраны (#129)
assert settings.get(conn, "board_id") == 4216
assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new 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'")} 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" # завершённые не трогаем assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем

View File

@ -189,3 +189,36 @@ def test_sweep_downloads(tmp_path):
assert files.sweep_downloads(root) == ["31337"] assert files.sweep_downloads(root) == ["31337"]
assert not old.exists() and fresh.exists() and (root / "loose.txt").exists() assert not old.exists() and fresh.exists() and (root / "loose.txt").exists()
assert files.sweep_downloads(tmp_path / "нет") == [] assert files.sweep_downloads(tmp_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]
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])
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])
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 == ""

View File

@ -34,3 +34,10 @@ def test_env_home(monkeypatch, tmp_path):
from sdlc_hub.core import paths from sdlc_hub.core import paths
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x"))
assert paths.home() == 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"

View File

@ -1,5 +1,6 @@
import inspect import inspect
import asyncio import asyncio
import unicodedata
import hashlib import hashlib
import json import json
import os import os
@ -952,3 +953,23 @@ def test_batch_create_description_lists_sprint_types():
doc = jmcp.batch_create.__doc__ doc = jmcp.batch_create.__doc__
for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"): for word in ("ensure_sprint_meta", "ensure_sprint_state", "ensure_rank", "story_points", "refresh по JQL проекта"):
assert word in doc 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

View File

@ -369,6 +369,7 @@ def test_attachment_moves_to_other_key(conn):
@pytest.mark.parametrize("bad, field", [ @pytest.mark.parametrize("bad, field", [
("x", "attachments"), (["x"], "attachments[0]"), ("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": "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": "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": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"),
([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"), ([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"),

View File

@ -601,7 +601,7 @@ def test_attachment_without_file_rejected(conn, store):
@pytest.mark.parametrize("params, msg", [ @pytest.mark.parametrize("params, msg", [
({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"),
({"file": SHA[:63]}, "команда 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": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"),
({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), ({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"),
@ -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" 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): def test_request_fetch_after_failed(fconn, tmp_path):
root = tmp_path / "files" root = tmp_path / "files"
first = jira_fetch.request_fetch(fconn, root, "31337", "x") first = jira_fetch.request_fetch(fconn, root, "31337", "x")
@ -1367,3 +1373,11 @@ def test_retry_sprint_meta_gets_jql_tail(conn, add_project):
cmds = queue.batch_view(conn, r["id"])["commands"] cmds = queue.batch_view(conn, r["id"])["commands"]
assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"] assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"]
assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} 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

View File

@ -298,3 +298,100 @@ def test_invalid_manifest_warning_not_repeated_across_alternating_dirs(tmp_path,
assert release.current(bad) is None assert release.current(bad) is None
assert release.current(none) is None assert release.current(none) is None
assert len([r for r in caplog.records if "манифест" in r.getMessage()]) == 1 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() # _undo вернул .prev на место agent.py
assert sorted(p.name for p in rd.iterdir()) == ["agent.py", "manifest.json"]
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

View File

@ -138,3 +138,48 @@ def test_sprint_types_pacing(conn):
assert jira_settings.pacing(conn)["ensure_rank"] == 30 assert jira_settings.pacing(conn)["ensure_rank"] == 30
settings.validate("pacing", {"ensure_rank": 10}) settings.validate("pacing", {"ensure_rank": 10})
assert queue.estimate(conn, ["ensure_sprint_meta", "ensure_sprint_state", "ensure_rank"]) == 90 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"] is None # 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)

View File

@ -228,3 +228,12 @@ def test_page_attachment_failed_error_visible(api):
c.close() c.close()
att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0] att = api.client.get("/wiki/pages/DOC-3", project="AWPCA")["attachments"][0]
assert att["state"] == "failed" and att["error"] == "Eva: 404" 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 # пакета не создано