diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 54e232f..36c2ce4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | -| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | @@ -34,6 +34,7 @@ ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. +- Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. diff --git a/README.md b/README.md index 5c846b6..4ee25c4 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | | `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | +| `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | | `refresh` | `keys` или `jql` | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть @@ -213,7 +214,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub ``` Ответ: `{id, estimate_seconds}`. Зазоры между записями в Jira по умолчанию: `ensure_issue` и `ensure_fields` 120 с, -`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint` 30 с, `refresh` 2 с (настройка `pacing`). +`ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment` 30 с, `refresh` 2 с (настройка `pacing`). + +`ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не +загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба +(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет). ## Пример сценария diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 680b93b..6f324ca 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -122,3 +122,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `Content-Type: a перезапусках хаба в сутки уборок больше одной (спека уточнена: «в пределах работы процесса», рулинг R-1). 16. 15.10.2026 — `GET /agent/files/{id}` с id не из 64 hex нижнего регистра — 404 `no_file`, а не общий `not_found`. Обоснование: для агента это тот же случай «файла нет». Цена: нет. +17. 15.10.2026 — тексты 422 без формулировки в спеке: «команда N: file — sha256 из POST /files (64 символа 0-9a-f)», + «команда N: filename пустой» (только пробелы; пустая строка — общее «нет filename; ожидаются …»), «команда N: + filename без / и \ — имя вложения, а не путь». Порядок проверок: формат `file`, наличие файла, `filename`. + Цена: формулировки правятся одной строкой. +18. 15.10.2026 — `queue.create_batch`, `queue.retry_command` и `queue.claim` получают `files_dir` параметром (по + умолчанию `None`: вложение не ставится — «файл не загружен», при выдаче `size = null`); `api` передаёт + `Hub.files_dir`; `GET /agent/commands/{id}` дописывает `size` через `queue.deliver_params`. Обоснование: `queue` + остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без `files_dir` молча отвергает вложения. diff --git a/openspec/changes/bug-and-attachments/specs/commands/spec.md b/openspec/changes/bug-and-attachments/specs/commands/spec.md index c9ffc3f..329361c 100644 --- a/openspec/changes/bug-and-attachments/specs/commands/spec.md +++ b/openspec/changes/bug-and-attachments/specs/commands/spec.md @@ -135,7 +135,8 @@ SHALL перевести её в `confirmed_by_state`: `ensure_fields` — вс При постановке пакета хаб SHALL отвергать 422 без создания пакета: `ensure_issue` с `issue_type = bug`, если в настройках нет `issue_types.bug` с непустым `name` или `id` («нет настройки issue_types.bug»); `ensure_attachment`, если `file` — не 64 hex-символа в нижнем регистре, файла с таким sha256 нет в хранилище хаба («файл не загружен: POST /files»), -`filename` пустой или содержит `/` или `\`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту +`filename` пустой (или из одних пробелов) или содержит `/`, `\`, `"` или управляющие символы (код < 32 и 127, +в том числе CR и LF): такие имена ломают `multipart/form-data`. Клиент `size` не передаёт (лишний параметр — 422). При выдаче агенту (`claim`, в том числе повторной) хаб SHALL добавить к параметрам `ensure_attachment` `size` — текущий размер файла в хранилище, или `size = null`, если файла уже нет; в базе параметры команды остаются без `size`, поэтому повтор команды (`POST /commands/{id}/retry`) проходит проверку как новая. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index ef6419e..a1f71d4 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -144,7 +144,7 @@ def h_claim(hub, conn, m, q, body): deadline = time.monotonic() + hub.claim_wait while True: cmd = queue.claim(conn, body.get("agent_version"), body.get("settings_version"), body.get("last_jira_call_at"), - backup=hub.make_backup) + backup=hub.make_backup, files_dir=hub.files_dir) if cmd or time.monotonic() >= deadline: break time.sleep(1) @@ -178,7 +178,9 @@ def h_agent_settings(hub, conn, m, q, body): def h_agent_command(hub, conn, m, q, body): - return 200, _not_found(queue.get_command(conn, _int(m)), "command") + """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" + cmd = _not_found(queue.get_command(conn, _int(m)), "command") + return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): @@ -270,7 +272,7 @@ def h_batches_post(hub, conn, m, q, body): if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") try: - return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands")) + return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -313,7 +315,7 @@ def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") try: - return 201, queue.retry_command(conn, _int(m), body.get("author") or "api") + return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir) except KeyError: raise ApiError(404, "not_found", "command") except CommandError as e: diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index bc81aac..f864ce7 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -31,6 +31,9 @@ SPEC = { "hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}}, "ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"], "hints": {"sprint": "имя спринта или backlog"}}, + "ensure_attachment": {"required": ["key", "file", "filename"], "optional": [], "keys": ["key"], + "hints": {"file": "sha256 из POST /files; в MCP — path к локальному файлу", + "filename": "имя вложения"}}, "refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"}, "note": "keys или jql"}, } @@ -182,11 +185,12 @@ def _norm_text(value): def check_confirmed(conn, cmd, link_types: dict) -> tuple[bool, dict | None]: - """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment и refresh по состоянию не подтверждаются: - потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля (bridge-reliability, - решение 4); совпадение темы с задачей неизвестного автора не доказательство.""" + """Виден ли желаемый результат в зеркале. ensure_issue, ensure_comment, ensure_attachment и refresh по состоянию не + подтверждаются: потерянный отчёт ensure_issue закрывается довыдачей — агент находит свою задачу поиском дубля + (bridge-reliability, решение 4); совпадение темы с задачей неизвестного автора не доказательство; вложений в + зеркале нет (bug-and-attachments).""" ctype, p = cmd["type"], cmd["params"] - if ctype in ("ensure_issue", "ensure_comment", "refresh"): + if ctype in ("ensure_issue", "ensure_comment", "ensure_attachment", "refresh"): return False, None issue = mirror.get_issue(conn, p.get("key") or p.get("source")) if issue is None: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3bcad31..66d2dcc 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -7,7 +7,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C -from . import db, mirror, settings +from . import db, files, mirror, settings log = logging.getLogger(__name__) @@ -45,19 +45,33 @@ def _dump(value) -> str: # --- пакеты ----------------------------------------------------------------- -def _check_setup(conn, cmds) -> None: - """Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета. - Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен).""" +def _check_setup(conn, cmds, files_dir) -> None: + """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 + до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). + files_dir=None — хранилища нет: любой файл «не загружен».""" bug = (settings.get(conn, "issue_types") or {}).get("bug") bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) for i, c in enumerate(cmds, 1): - if c["type"] == "ensure_issue" and c["params"]["issue_type"] == "bug" and not bug_ok: + p = c["params"] + if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") + if c["type"] == "ensure_attachment": + if not files.is_sha(p["file"]): + raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") + if files_dir is None or not files.exists(files_dir, p["file"]): + raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") + if not p["filename"].strip(): + raise C.CommandError(f"команда {i}: filename пустой") + if "/" in p["filename"] or "\\" in p["filename"]: + raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") + if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]): + # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) + raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") -def create_batch(conn, title, author, cmds) -> dict: +def create_batch(conn, title, author, cmds, files_dir=None) -> dict: cmds = C.validate(cmds) - _check_setup(conn, cmds) + _check_setup(conn, cmds, files_dir) keys = [] for c in cmds: keys += [k for k in C.referenced_keys(c) if k not in keys] @@ -172,7 +186,16 @@ def _redeliver(conn, now) -> dict | None: log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) -def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None) -> dict | None: +def deliver_params(cmd, files_dir) -> dict: + """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, + если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + if cmd["type"] != "ensure_attachment": + return cmd["params"] + size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None + return {**cmd["params"], "size": size} + + +def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: now = db.now() to_backup = None with db.transaction(conn): @@ -214,7 +237,8 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non backup(to_backup) except Exception: # сбой копии не должен держать очередь: команда уже взята log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True) - return {"id": cmd["id"], "type": cmd["type"], "params": cmd["params"], "pause_seconds": pause, "batch_id": cmd["batch_id"]} + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause, + "batch_id": cmd["batch_id"]} # --- отчёты ----------------------------------------------------------------- @@ -325,11 +349,12 @@ def cancel_command(conn, command_id, force=False) -> dict: return get_command(conn, command_id) -def retry_command(conn, command_id, author) -> dict: +def retry_command(conn, command_id, author, files_dir=None) -> dict: cmd = get_command(conn, command_id) if cmd is None: raise KeyError(command_id) - return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}]) + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], + files_dir=files_dir) # --- агент и автообновление ---------------------------------------------------- diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index f2fc533..23fcf88 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -5,7 +5,7 @@ import os import secrets DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "refresh": 2} + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} DEFAULTS = { "base_jql": "project = AURORA AND labels = AWPCA", @@ -132,7 +132,7 @@ def pacing_from_config(p: dict) -> dict: """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "refresh": 2} + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2} def _issue_types(cfg: dict) -> dict: diff --git a/tests/test_api.py b/tests/test_api.py index c4cddd5..9554794 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -708,3 +708,22 @@ def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, cap hub["hub"].tick() # не бросает assert "уборка файлов: сбой" in caplog.text assert [b["title"] for b in hub["client"].get("/batches")["batches"]] == ["Автообновление"] + + +def test_attachment_flow_over_api(hub): + """POST /batches проверяет файл; claim и GET /agent/commands/{id} отдают size; retry — params без size.""" + c, a = hub["client"], hub["agent"] + sha = c.post("/files", {"content": _b64(b"abc")})["file"] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": "0" * 64, "filename": "a.xlsx"}}]}) + assert e.value.status == 422 and e.value.detail == f"команда 1: файл {'0' * 64} не загружен: POST /files" + c.post("/batches", {"title": "x", "commands": [{"type": "ensure_attachment", "params": { + "key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"}}]}) + cmd = a.post("/agent/claim", {})["command"] + assert cmd["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx", "size": 3} + assert cmd["pause_seconds"] == 30 + assert a.get(f"/agent/commands/{cmd['id']}")["params"]["size"] == 3 + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 500"}) + retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) + assert c.get(f"/batches/{retry['id']}")["commands"][0]["params"] == {"key": "A-1", "file": sha, "filename": "ОСВ 60.xlsx"} diff --git a/tests/test_commands.py b/tests/test_commands.py index fdcc4d3..9d52d63 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -64,6 +64,27 @@ def test_validate_accepts_bug_issue_type(): assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug" +ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc") + + +def test_ensure_attachment_contract(): + ok = commands.validate([ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}}, + {"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}]) + assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"] + assert "ensure_attachment" in commands.WRITE_TYPES + assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), " + "filename (имя вложения)") in commands.describe().splitlines() + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}]) + assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename" + with pytest.raises(CommandError) as e: + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}]) + assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename" + with pytest.raises(CommandError, match="file — строка"): + commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}]) + + def test_spec_hints_name_known_params(): for ctype, spec in commands.SPEC.items(): assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype @@ -109,6 +130,7 @@ LT = {"связанные": "ASUPR_Связанные", "блокирует на ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True), ({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False), ({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False), + ({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False), ({"type": "refresh", "params": {"keys": ["A-1"]}}, False), ({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False), ]) diff --git a/tests/test_queue.py b/tests/test_queue.py index 750c1d4..aea73c9 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,6 +1,8 @@ +import hashlib + import pytest -from jira_hub import db, mirror, queue, settings +from jira_hub import db, files, mirror, queue, settings from jira_hub.commands import CommandError from jira_hub.queue import ClaimedError @@ -522,6 +524,21 @@ def bug(temp="new:1", summary="ОСВ 60 не распознана"): "labels": ["AWPCA"]}} +SHA = hashlib.sha256(b"abc").hexdigest() + + +@pytest.fixture +def store(tmp_path): + """Каталог хранилища с файлом «abc».""" + root = tmp_path / "files" + files.put(root, b"abc") + return root + + +def attach(key="A-1", file=SHA, filename="ОСВ 60.xlsx"): + return {"type": "ensure_attachment", "params": {"key": key, "file": file, "filename": filename}} + + def test_bug_without_setting_rejected(conn): """Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан.""" settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}}) @@ -543,3 +560,82 @@ def test_bug_with_setting_accepted(conn, value): settings.set_(conn, "issue_types", {"bug": value}) b = queue.create_batch(conn, "ошибка", "claude", [bug()]) assert b["count"] == 2 # ensure_issue + хвостовой refresh + + +def test_attachment_without_file_rejected(conn, store): + """Спека commands «Вложение без загруженного файла»: 422, пакет не создан.""" + missing = "0" * 64 + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [attach(file=missing)], files_dir=store) + assert str(e.value) == f"команда 1: файл {missing} не загружен: POST /files" + with pytest.raises(CommandError, match=f"файл {SHA} не загружен"): + queue.create_batch(conn, "вложение", "claude", [attach()]) # хранилище не передано + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +@pytest.mark.parametrize("params, msg", [ + ({"file": SHA.upper()}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"file": SHA[:63]}, "команда 1: file — sha256 из POST /files (64 символа 0-9a-f)"), + ({"filename": " "}, "команда 1: filename пустой"), + ({"filename": "docs/ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": "C:\\ОСВ.xlsx"}, "команда 1: filename без / и \\ — имя вложения, а не путь"), + ({"filename": 'ОСВ "60".xlsx'}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\n60.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\r.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), + ({"filename": "ОСВ\x7f.xlsx"}, "команда 1: filename без кавычек и управляющих символов (CR, LF и др.)"), +]) +def test_attachment_params_rejected(conn, store, params, msg): + cmd = attach() + cmd["params"].update(params) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "вложение", "claude", [cmd], files_dir=store) + assert str(e.value) == msg + + +def test_bug_with_attachment_one_batch(conn, store): + """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}}) + b = queue.create_batch(conn, "ошибка", "claude", [ + bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")], + files_dir=store) + assert b["count"] == 4 and b["estimate_seconds"] == 120 + 30 + 30 + 2 + c1 = queue.claim(conn, files_dir=store) + assert c1["type"] == "ensure_issue" + queue.report_result(conn, c1["id"], "done", result={"key": "AURORA-1650"}) + c2 = queue.claim(conn, files_dir=store) + assert c2["type"] == "ensure_sprint" + queue.report_result(conn, c2["id"], "done") + c3 = queue.claim(conn, files_dir=store) + assert c3["type"] == "ensure_attachment" and c3["pause_seconds"] == 30 + assert c3["params"] == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx", "size": 3} + again = queue.claim(conn, files_dir=store) # довыдача — тоже с size + assert again["id"] == c3["id"] and again["params"]["size"] == 3 + stored = queue.get_command(conn, c3["id"])["params"] + assert stored == {"key": "AURORA-1650", "file": SHA, "filename": "ОСВ 60.xlsx"} # в базе без size + + +def test_attachment_size_null_when_file_gone(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + (store / SHA).unlink() + c = queue.claim(conn, files_dir=store) + assert c["type"] == "ensure_attachment" and c["params"]["size"] is None + assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение + assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None + + +def test_attachment_retry_passes_validation(conn, store): + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + queue.report_result(conn, c["id"], "failed", error="HTTP 413") + r = queue.retry_command(conn, c["id"], "claude", files_dir=store) + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "file": SHA, "filename": "ОСВ 60.xlsx"} + (store / SHA).unlink() # убран уборкой + with pytest.raises(CommandError, match=f"команда 1: файл {SHA} не загружен: POST /files"): + queue.retry_command(conn, c["id"], "claude", files_dir=store) + + +def test_attachment_not_confirmed_by_state(conn, store): + mirror.apply_state(conn, [{"key": "A-1", "summary": "Т", "labels": []}], []) + queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) + c = queue.claim(conn, files_dir=store) + assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed" diff --git a/tests/test_settings.py b/tests/test_settings.py index 7803ab4..9043c62 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -87,3 +87,10 @@ def test_init_bug_from_config(conn, extra, expected): types = settings.get(conn, "issue_types") assert types["bug"] == expected assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} + + +def test_attachment_pacing(conn): + assert settings.DEFAULT_PACING["ensure_attachment"] == 30 + assert settings.pacing(conn)["ensure_attachment"] == 30 + assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 + settings.validate("pacing", {"ensure_attachment": 10})