feat(commands): тип bug в ensure_issue — проверка настройки issue_types.bug при постановке (#43)

validate принимает task|epic|bug; queue._check_setup отвергает bug без issue_types.bug с name или id (422 «команда N: нет настройки issue_types.bug»); init берёт bug из issue_type_bug/issue_type_bug_id, только если ключи есть (решение 9). Спека hub-api: уборка файлов раз в сутки в пределах процесса (R-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:35:56 +04:00
parent a8672cf7c0
commit 3b4cf66b77
10 changed files with 105 additions and 9 deletions

View File

@ -186,7 +186,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
| Тип | Параметры |
|---|---|
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
| `ensure_issue` | `temp_key` (`new:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` |
| `ensure_fields` | `key` и подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` |
| `ensure_comment` | `key`, `marker`, `body` |
| `ensure_status` | `key`, `target` (имя статуса или список-путь) |
@ -194,6 +194,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) |
| `refresh` | `keys` или `jql` |
Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть
`issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из
ключей конфига `issue_type_bug`/`issue_type_bug_id`, если они есть; на живом хабе — `settings_set issue_types` с объектом
целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`.
Ключ в параметре может быть временным `new:<n>`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):

View File

@ -97,3 +97,6 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
Обоснование: `validate` не знает каталог данных. Цена: два места проверки параметров одной команды.
8. 15.10.2026 — в change включены M-1 и M-2 из aep/jira-agent#30 и R-1 из aep/jira-hub#46 (по порядку, согласованному
владельцем 15.10.2026). M-3, M-4 из jira-agent#30 остаются в follow-up. Цена: change немного шире двух issue.
9. 15.10.2026 — `issue_type` вне списка — 422 «команда N: issue_type — task, epic или bug»; `init` добавляет `bug`, если
ключ `issue_type_bug` или `issue_type_bug_id` есть в конфиге даже со значением `null` (тогда постановка `bug` — 422,
как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет.

View File

@ -144,7 +144,7 @@ sha256 (без расширения и имени вложения: имя за
- файл больше 10 МБ (10 485 760 байт) после декодирования — 413 `too_large` (тело запроса до 16 МБ проходит);
- запись атомарная: временный файл в `files/`, затем переименование; повторная загрузка того же содержимого отвечает тем
же `file` и не создаёт второй копии.
Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки удалять файлы старше 30 дней (по
Файлы в бэкапы базы MUST NOT входить. Фоновый `tick` хаба SHALL не чаще раза в сутки в пределах работы процесса (после старта — на первом тике) удалять файлы старше 30 дней (по
времени изменения), на которые не ссылается ни одна команда `ensure_attachment` в `queued` или `claimed`; повтор команды
после уборки отвергается при постановке 422 «файл <sha> не загружен: POST /files». Повторная загрузка того же
содержимого SHALL обновлять время изменения файла.

View File

@ -18,7 +18,7 @@ SPEC = {
"optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels",
"epic_key", "assignee"],
"keys": ["temp_key", "epic_key"],
"hints": {"temp_key": "new:<n>", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}},
"hints": {"temp_key": "new:<n>", "issue_type": "task|epic|bug", "labels": "список", **FIELD_HINTS}},
"ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
"keys": ["key", "epic_key"],
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
@ -139,8 +139,8 @@ def validate(commands) -> list[dict]:
raise CommandError(f"команда {i}: temp_key должен быть new:<n>")
if params["temp_key"] in defined:
raise CommandError(f"команда {i}: temp_key {params['temp_key']} уже объявлен")
if params["issue_type"] not in ("task", "epic"):
raise CommandError(f"команда {i}: issue_type — task или epic")
if params["issue_type"] not in ("task", "epic", "bug"):
raise CommandError(f"команда {i}: issue_type — task, epic или bug")
for key in temp_keys_used({"type": ctype, "params": params}):
if key not in defined:
raise CommandError(f"команда {i}: временный ключ {key} не объявлен ранее в пакете командой ensure_issue")

View File

@ -45,8 +45,19 @@ def _dump(value) -> str:
# --- пакеты -----------------------------------------------------------------
def _check_setup(conn, cmds) -> None:
"""Проверки постановки, которым нужны настройки хаба (bug-and-attachments, решения 6–7): 422 до создания пакета.
Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен)."""
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:
raise C.CommandError(f"команда {i}: нет настройки issue_types.bug")
def create_batch(conn, title, author, cmds) -> dict:
cmds = C.validate(cmds)
_check_setup(conn, cmds)
keys = []
for c in cmds:
keys += [k for k in C.referenced_keys(c) if k not in keys]

View File

@ -135,6 +135,15 @@ def pacing_from_config(p: dict) -> dict:
"ensure_link": comment, "ensure_sprint": comment, "refresh": 2}
def _issue_types(cfg: dict) -> dict:
"""issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть
issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»)."""
out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")}
if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg:
out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")}
return out
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
@ -144,8 +153,7 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]:
values = {
"project_key": project,
"base_jql": f"project = {project} AND labels = AWPCA",
"issue_types": {"task": {"name": cfg.get("issue_type_task"), "id": cfg.get("issue_type_task_id")},
"epic": {"name": cfg.get("issue_type_epic"), "id": cfg.get("issue_type_epic_id")}},
"issue_types": _issue_types(cfg),
"epic_name_field_id": cfg.get("epic_name_field_id"),
"field_ids": cfg.get("fields", {}),
"epic_link": cfg.get("epic_link", {}),

View File

@ -598,3 +598,11 @@ def test_issue_detail_with_comments(hub):
got = hub["client"].get("/issues/A-1")
assert [x["body"] for x in got["comments"]] == ["новый", "старый"]
assert got["epic_summary"] == "Эпик"
def test_bug_without_setting_is_422(hub):
with pytest.raises(HubError) as e:
hub["client"].post("/batches", {"title": "ошибка", "commands": [{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}]})
assert e.value.status == 422 and e.value.detail == "команда 1: нет настройки issue_types.bug"
assert hub["client"].get("/batches")["batches"] == []

View File

@ -28,7 +28,8 @@ def test_validate_ok_and_tail_keys():
([{"type": "refresh", "params": {}}], "без keys и jql"),
([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"),
([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:<n>"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "x"}}], "task или epic"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}],
"task, epic или bug"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}},
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"),
])
@ -55,6 +56,14 @@ def test_unknown_param_error_names_expected():
"ожидаются source, target, link_type; необязательные replace")
def test_validate_accepts_bug_issue_type():
"""bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue)."""
cmds = commands.validate([{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}])
assert cmds[0]["params"]["issue_type"] == "bug"
assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug"
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

View File

@ -515,3 +515,31 @@ def test_delivery_limit_of_write_command_keeps_open_sync_run(conn):
assert queue.claim(conn)["id"] == rid # c — failed по пределу, довыдаётся refresh
assert queue.get_command(conn, c["id"])["status"] == "failed"
assert conn.execute("SELECT finished_at FROM sync_run WHERE command_id = ?", (rid,)).fetchone()["finished_at"] is None
def bug(temp="new:1", summary="ОСВ 60 не распознана"):
return {"type": "ensure_issue", "params": {"temp_key": temp, "issue_type": "bug", "summary": summary,
"labels": ["AWPCA"]}}
def test_bug_without_setting_rejected(conn):
"""Спека commands «Ошибка без настройки типа»: 422 с номером команды, пакет не создан."""
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}})
with pytest.raises(CommandError) as e:
queue.create_batch(conn, "ошибка", "claude", [fields("A-1"), bug()])
assert str(e.value) == "команда 2: нет настройки issue_types.bug"
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0
@pytest.mark.parametrize("value", [{"name": None, "id": None}, {"name": "", "id": ""}, "Ошибка", None])
def test_bug_with_empty_setting_rejected(conn, value):
settings.set_(conn, "issue_types", {"task": {"name": "Задача", "id": None}, "bug": value})
with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
queue.create_batch(conn, "ошибка", "claude", [bug()])
@pytest.mark.parametrize("value", [{"name": "Ошибка", "id": None}, {"name": None, "id": "10102"}])
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

View File

@ -3,7 +3,8 @@ from pathlib import Path
import pytest
from jira_hub import settings
from jira_hub import queue, settings
from jira_hub.commands import CommandError
FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json"
@ -63,3 +64,26 @@ def test_relay_dir_default_and_validation(tmp_path, open_db):
with pytest.raises(ValueError):
settings.validate("relay_dir", bad)
assert "relay_dir" not in settings.AGENT_KEYS
def test_init_without_bug_keys_rejects_bug(conn):
"""Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422."""
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
settings.init_from_config(conn, cfg)
assert set(settings.get(conn, "issue_types")) == {"task", "epic"}
with pytest.raises(CommandError, match="нет настройки issue_types.bug"):
queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "x", "labels": ["AWPCA"]}}])
@pytest.mark.parametrize("extra, expected", [
({"issue_type_bug": "Ошибка"}, {"name": "Ошибка", "id": None}),
({"issue_type_bug_id": "10102"}, {"name": None, "id": "10102"}),
({"issue_type_bug": "Ошибка", "issue_type_bug_id": "10102"}, {"name": "Ошибка", "id": "10102"}),
])
def test_init_bug_from_config(conn, extra, expected):
cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra}
settings.init_from_config(conn, cfg)
types = settings.get(conn, "issue_types")
assert types["bug"] == expected
assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"}