Хаб: доска проекта в ensure_sprint, boards в выдаче refresh, типы досок из отчёта агента (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:58:39 +04:00
parent 6d5c40b037
commit e2e63dab23
6 changed files with 245 additions and 15 deletions

View File

@ -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:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`.

View File

@ -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):

View File

@ -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)

View File

@ -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

View File

@ -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"])

View File

@ -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