feat(commands): команда ensure_attachment — проверка файла при постановке, size при выдаче (#43)
SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e8707d4762
commit
9b21060d60
@ -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/<sha256>` (каталог — параметр, `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/<sha256>` → `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`.
|
||||
|
||||
@ -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:<n>`), `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: файл <sha> не
|
||||
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба
|
||||
(`GET /agent/files/{sha256}`) и прикладывает. По зеркалу команда не подтверждается (вложений в зеркале нет).
|
||||
|
||||
## Пример сценария
|
||||
|
||||
|
||||
@ -122,3 +122,11 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `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` молча отвергает вложения.
|
||||
|
||||
@ -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 нет в хранилище хаба («файл <sha> не загружен: 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`) проходит проверку как новая.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# --- агент и автообновление ----------------------------------------------------
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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),
|
||||
])
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user