diff --git a/README.md b/README.md index 7b32379..4fd2dce 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | Тип | Параметры | |---|---| -| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | +| `ensure_issue` | `temp_key` (`new:`), `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:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`): diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index f72eb2e..a0baf5f 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -97,3 +97,6 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `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, + как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет. diff --git a/openspec/changes/bug-and-attachments/specs/hub-api/spec.md b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md index b9931ba..f470721 100644 --- a/openspec/changes/bug-and-attachments/specs/hub-api/spec.md +++ b/openspec/changes/bug-and-attachments/specs/hub-api/spec.md @@ -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 «файл не загружен: POST /files». Повторная загрузка того же содержимого SHALL обновлять время изменения файла. diff --git a/src/jira_hub/commands.py b/src/jira_hub/commands.py index baf1b6c..bc81aac 100644 --- a/src/jira_hub/commands.py +++ b/src/jira_hub/commands.py @@ -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:", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}}, + "hints": {"temp_key": "new:", "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:") 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") diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 3faead8..3bcad31 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -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] diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index fc91e75..f2fc533 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -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", {}), diff --git a/tests/test_api.py b/tests/test_api.py index e11d7c0..dc3bdf9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"] == [] diff --git a/tests/test_commands.py b/tests/test_commands.py index 45f0783..fdcc4d3 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -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:"), - ([{"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 diff --git a/tests/test_queue.py b/tests/test_queue.py index a6732ae..750c1d4 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -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 diff --git a/tests/test_settings.py b/tests/test_settings.py index 4926338..7803ab4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -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"}