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:
parent
a8672cf7c0
commit
3b4cf66b77
@ -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`):
|
||||
|
||||
|
||||
@ -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,
|
||||
как без настройки). Обоснование: спека задаёт условие по наличию ключей, не по значению. Цена: нет.
|
||||
|
||||
@ -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 обновлять время изменения файла.
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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", {}),
|
||||
|
||||
@ -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"] == []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user