From e2e63dab23897f272b1cf018aeb0d2097b525856 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:58:39 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A5=D0=B0=D0=B1:=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=B2=20?= =?UTF-8?q?ensure=5Fsprint,=20boards=20=D0=B2=20=D0=B2=D1=8B=D0=B4=D0=B0?= =?UTF-8?q?=D1=87=D0=B5=20refresh,=20=D1=82=D0=B8=D0=BF=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BE=D0=BA=20=D0=B8=D0=B7=20=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=B0=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20(#6?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 6 + src/jira_hub/api.py | 2 +- src/jira_hub/queue.py | 40 +++++- tests/test_api.py | 22 ++++ tests/test_e2e_hub.py | 8 +- tests/test_queue.py | 182 ++++++++++++++++++++++++++-- 6 files changed, 245 insertions(+), 15 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 5e7b5a1..dc44cfa 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -264,3 +264,9 @@ 26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command` сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда #N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422. +27. 2026-10-19 — **типы досок из результата `refresh` пишутся только при `status = done`; результат без ключа `boards` + или с мусорными значениями типы не трогает.** Обоснование: у проваленной команды результат неполон, а агент v11 ключа + не присылает. Цена ошибки: одно условие в `report_result`. +28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без + обращения к зеркалу.** Обоснование: `new:` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта) + единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 5c5e7fb..e277fb6 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -180,7 +180,7 @@ def h_agent_settings(hub, conn, m, q, body): def h_agent_command(hub, conn, m, q, body): """Команда по 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)} + return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index b2a1942..94a786f 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -67,6 +67,32 @@ def _check_setup(conn, cmds, files_dir) -> None: 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 и др.)") + _assign_boards(conn, cmds) + + +def _assign_boards(conn, cmds) -> None: + """Доска проекта задачи в params ensure_sprint (design #7): метки временного ключа — из его ensure_issue в пакете, + иначе из зеркала; 422 — задача вне проектов, разные доски, нет доски, канбан, тип ещё не известен.""" + temp_labels = {c["params"]["temp_key"]: c["params"].get("labels") or [] for c in cmds if c["type"] == "ensure_issue"} + found = projects.list_projects(conn) + for i, c in enumerate(cmds, 1): + if c["type"] != "ensure_sprint": + continue + key = c["params"]["key"] + labels = temp_labels[key] if key in temp_labels else (mirror.labels_of(conn, key) or []) + own = [p for p in found if p["label"] in labels] + if not own: + raise C.CommandError(f"команда {i}: {key} не входит ни в один проект — доску для спринта не определить") + if len({p["board_id"] for p in own}) > 1: + raise C.CommandError(f"команда {i}: {key} в проектах {', '.join(p['label'] for p in own)} с разными досками") + p = own[0] + if p["board_id"] is None: + raise C.CommandError(f"команда {i}: у проекта {p['label']} нет доски Jira") + if p["board_type"] == "kanban": + raise C.CommandError(f"команда {i}: у проекта {p['label']} канбан-доска, спринтов нет") + if p["board_type"] is None: + raise C.CommandError(f"команда {i}: тип доски проекта {p['label']} ещё не известен — дождитесь обновления из Jira") + c["params"] = {**c["params"], "board_id": p["board_id"]} def _check_project(conn, cmds, project) -> list[dict]: @@ -230,9 +256,12 @@ def _redeliver(conn, now) -> dict | None: log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) -def deliver_params(cmd, files_dir) -> dict: +def deliver_params(conn, cmd, files_dir) -> dict: """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, - если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них: + повтор команды проходит проверку как новая.""" + if cmd["type"] == "refresh": + return {**cmd["params"], "boards": projects.boards(conn)} 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 @@ -281,7 +310,7 @@ 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": deliver_params(cmd, files_dir), "pause_seconds": pause, + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(conn, cmd, files_dir), "pause_seconds": pause, "batch_id": cmd["batch_id"]} @@ -333,6 +362,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= if cmd["type"] == "refresh": conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) + if status == "done" and isinstance(result, dict) and "boards" in result: + projects.record_board_types(conn, result["boards"]) # без ключа (агент v11) типы не трогаем if status == "done" and is_full_refresh(conn, command_id): # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) @@ -400,7 +431,8 @@ def retry_command(conn, command_id, author, files_dir=None, project=None) -> dic origin = conn.execute("SELECT project FROM batch WHERE id = ?", (cmd["batch_id"],)).fetchone()["project"] if project is not None and project != origin: raise C.CommandError(f"команда #{command_id} не из проекта {project}") - return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], + params = {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")} + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": params}], files_dir=files_dir, project=origin) diff --git a/tests/test_api.py b/tests/test_api.py index 9365f92..d945af3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -886,3 +886,25 @@ def test_project_batches_api(hub, add_project): # #61, T assert len(c.get("/batches", limit=50)["batches"]) == before r = c.post(f"/commands/{cid}/retry", {}) assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA" + + +def test_agent_command_refresh_has_boards(hub, add_project): # #61, Task 6 + _add_project(hub, add_project) + b = hub["client"].post("/batches", {"title": "r", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) + cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] + got = hub["agent"].get(f"/agent/commands/{cid}") + assert got["params"] == {"keys": ["A-1"], "boards": [{"id": 4216, "type": "scrum"}]} + assert "boards" not in hub["client"].get(f"/batches/{b['id']}")["commands"][0]["params"] + + +def test_board_type_round_trip(hub): # сценарий «Тип доски определён» + c, a = hub["client"], hub["agent"] + c.post("/projects", {"label": "STOCKS_DRRK", "name": "Остатки ДРРК", "board_id": 5120}) + cmd = a.post("/agent/claim", {"agent_version": 12, "settings_version": 1})["command"] # refresh от создания проекта + assert cmd["type"] == "refresh" and {"id": 5120, "type": None} in cmd["params"]["boards"] + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"boards": {"5120": "kanban"}}}) + assert [p["board_type"] for p in c.get("/projects")["projects"] if p["label"] == "STOCKS_DRRK"] == ["kanban"] + a.post("/agent/state", {"issues": [{"key": "A-5", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]}) + assert e.value.status == 422 and "канбан-доска" in e.value.detail diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 60c17b8..16db23d 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -7,11 +7,12 @@ from jira_hub.client import HubClient, HubError @pytest.fixture -def live(tmp_path): +def live(tmp_path, add_project): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h.claim_wait = 0 conn = h.conn() settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + add_project(conn) tok = settings.tokens(conn) conn.close() srv = api.make_server(h, "127.0.0.1", 0) @@ -28,7 +29,8 @@ def test_create_link_sprint_flow(live): agent, client = live client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [ - {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23", + "labels": ["AWPCA"]}}, {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, {"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}}, ]}) @@ -67,7 +69,7 @@ def test_create_link_sprint_flow(live): else: agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"] - assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"] + assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[2][1]["board_id"] == 4216 and seen[3][1]["keys"] == ["A-90", "A-1"] view = client.get(f"/batches/{bid}") assert view["status"] == "done" and view["remaining_seconds"] == 0 assert all(c["status"] == "done" for c in view["commands"]) diff --git a/tests/test_queue.py b/tests/test_queue.py index fce4aa1..b28c79e 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -96,12 +96,14 @@ def test_backup_failure_does_not_block_claim(conn, caplog): assert "не создана" in caplog.text -def test_temp_key_substitution_and_skip(conn): +def test_temp_key_substitution_and_skip(conn, add_project): + add_project(conn) mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") queue.create_batch(conn, "создать", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, - {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая", + "labels": ["AWPCA"]}}, {"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}}, ]) c1 = queue.claim(conn) @@ -144,7 +146,9 @@ def test_report_after_force_cancel_keeps_cancelled(conn): assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" -def test_reconcile_confirms_by_state(conn): +def test_reconcile_confirms_by_state(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], []) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) @@ -242,11 +246,13 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn): assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None -def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch): +def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch, add_project): """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], []) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) - mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": []}], []) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": ["AWPCA"]}], []) real = queue.C.check_confirmed def racing(*a, **kw): @@ -608,8 +614,9 @@ def test_attachment_params_rejected(conn, store, params, msg): assert str(e.value) == msg -def test_bug_with_attachment_one_batch(conn, store): +def test_bug_with_attachment_one_batch(conn, store, add_project): """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + add_project(conn) 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")], @@ -636,7 +643,7 @@ def test_attachment_size_null_when_file_gone(conn, store): 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 + assert queue.deliver_params(conn, queue.get_command(conn, c["id"]), store)["size"] is None def test_attachment_retry_passes_validation(conn, store): @@ -767,3 +774,164 @@ def test_retry_inherits_project_checks(conn, add_project): # Review with pytest.raises(CommandError, match="AURORA-1 не в проекте STOCKS_DRRK"): queue.retry_command(conn, cid, "claude") assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == before + + +# --- доски: ensure_sprint.board_id, refresh.boards, типы досок (#61, Task 6) ----------------------------------------- + +def sprint(key, name="Спринт 2"): + return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}} + + +def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216} + + +@pytest.mark.parametrize("setup, key, msg", [ + (dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"), + (dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"), + (dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"), + (dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале + (dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"), +]) +def test_sprint_board_rejected(conn, add_project, setup, key, msg): + add_project(conn) # AWPCA 4216 scrum + add_project(conn, "STOCKS_DRRK", **setup) + mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]}, + {"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], []) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [sprint(key)]) + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_sprint_rejected_message_has_command_number(conn, add_project): + add_project(conn, board_id=5120, board_type="kanban") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [fields("A-1"), sprint("A-1")]) + assert str(e.value) == "команда 2: у проекта AWPCA канбан-доска, спринтов нет" + + +def test_backlog_on_kanban_rejected(conn, add_project): + add_project(conn, board_id=5120, board_type="kanban") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + with pytest.raises(CommandError, match="канбан-доска"): + queue.create_batch(conn, "x", "claude", [sprint("A-1", "backlog")]) + + +def test_sprint_two_projects_same_board(conn, add_project): + add_project(conn) + add_project(conn, "C", board_id=4216) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA", "C"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("A-1")]) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["board_id"] == 4216 + + +def test_sprint_for_new_issue_uses_batch_labels(conn, add_project): + add_project(conn) + add_project(conn, "C", board_id=5120) + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")], + project="AWPCA") # метка проекта дописана Task 5 + assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 4216 + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Н2", "labels": ["C"]}}, + sprint("new:2")]) # без проекта — метки самой команды + assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 5120 + + +def test_sprint_for_new_issue_without_project_labels_rejected(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "new:1", "labels": ["AWPCA"]}], []) # зеркало не должно подменять метки пакета + with pytest.raises(CommandError, match="не входит ни в один проект"): + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")]) + + +def test_client_board_id_rejected(conn, add_project): + add_project(conn) + with pytest.raises(CommandError, match="лишние параметры board_id"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}]) + + +def test_input_params_not_mutated(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + cmds = [sprint("A-1")] + queue.create_batch(conn, "x", "claude", cmds) + assert cmds == [sprint("A-1")] + + +def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + + +def test_retry_premigration_sprint_without_board(conn, add_project): # Review Focus 5 + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES ('старый', 'claude', ?, 1, NULL)", + (db.now(),)) + cid = conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (?, 1, 'ensure_sprint', ?)", + (cur.lastrowid, '{"key": "A-1", "sprint": "Спринт 2"}')).lastrowid + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216} + + +def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 + add_project(conn) + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + add_project(conn, "C", board_id=4216) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}]) + got = queue.claim(conn) + assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + assert queue.claim(conn)["params"]["boards"] == got["params"]["boards"] # довыдача — тоже с boards + + +def test_refresh_boards_empty_list(conn, add_project): + add_project(conn, board_id=None, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert queue.claim(conn)["params"]["boards"] == [] + + +@pytest.mark.parametrize("result, expected", [ + ({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int + ({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет + ({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется + ({"boards": {"abc": "scrum"}}, None), + ({"boards": {"5120": "sprint"}}, None), # мусорный тип + ({"boards": ["5120", "kanban"]}, None), # boards не словарь + ({"boards": "kanban"}, None), + (None, None), +]) +def test_refresh_result_board_types(conn, add_project, result, expected): + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result=result) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected + + +def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): # доска в Jira стала канбаном + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type="scrum") + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result={"boards": {"5120": "kanban"}}) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" + + +def test_refresh_failed_result_keeps_board_types(conn, add_project): + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e") + assert projects.get(conn, "STOCKS_DRRK")["board_type"] is None