Хаб: доска проекта в ensure_sprint, boards в выдаче refresh, типы досок из отчёта агента (#61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d5c40b037
commit
e2e63dab23
@ -264,3 +264,9 @@
|
|||||||
26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command`
|
26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command`
|
||||||
сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда
|
сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда
|
||||||
#N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422.
|
#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`.
|
||||||
|
|||||||
@ -180,7 +180,7 @@ def h_agent_settings(hub, conn, m, q, body):
|
|||||||
def h_agent_command(hub, conn, m, q, body):
|
def h_agent_command(hub, conn, m, q, body):
|
||||||
"""Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment)."""
|
"""Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment)."""
|
||||||
cmd = _not_found(queue.get_command(conn, _int(m)), "command")
|
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):
|
def h_agent_result(hub, conn, m, q, body):
|
||||||
|
|||||||
@ -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"]):
|
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)
|
# кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2)
|
||||||
raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)")
|
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]:
|
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)
|
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,
|
"""Параметры команды для агента: 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":
|
if cmd["type"] != "ensure_attachment":
|
||||||
return cmd["params"]
|
return cmd["params"]
|
||||||
size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None
|
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)
|
backup(to_backup)
|
||||||
except Exception: # сбой копии не должен держать очередь: команда уже взята
|
except Exception: # сбой копии не должен держать очередь: команда уже взята
|
||||||
log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True)
|
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"]}
|
"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":
|
if cmd["type"] == "refresh":
|
||||||
conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?",
|
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))
|
(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):
|
if status == "done" and is_full_refresh(conn, command_id):
|
||||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||||
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (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"]
|
origin = conn.execute("SELECT project FROM batch WHERE id = ?", (cmd["batch_id"],)).fetchone()["project"]
|
||||||
if project is not None and project != origin:
|
if project is not None and project != origin:
|
||||||
raise C.CommandError(f"команда #{command_id} не из проекта {project}")
|
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)
|
files_dir=files_dir, project=origin)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -886,3 +886,25 @@ def test_project_batches_api(hub, add_project): # #61, T
|
|||||||
assert len(c.get("/batches", limit=50)["batches"]) == before
|
assert len(c.get("/batches", limit=50)["batches"]) == before
|
||||||
r = c.post(f"/commands/{cid}/retry", {})
|
r = c.post(f"/commands/{cid}/retry", {})
|
||||||
assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA"
|
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
|
||||||
|
|||||||
@ -7,11 +7,12 @@ from jira_hub.client import HubClient, HubError
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@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 = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups")
|
||||||
h.claim_wait = 0
|
h.claim_wait = 0
|
||||||
conn = h.conn()
|
conn = h.conn()
|
||||||
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
|
settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"})
|
||||||
|
add_project(conn)
|
||||||
tok = settings.tokens(conn)
|
tok = settings.tokens(conn)
|
||||||
conn.close()
|
conn.close()
|
||||||
srv = api.make_server(h, "127.0.0.1", 0)
|
srv = api.make_server(h, "127.0.0.1", 0)
|
||||||
@ -28,7 +29,8 @@ def test_create_link_sprint_flow(live):
|
|||||||
agent, client = live
|
agent, client = live
|
||||||
client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
|
client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"})
|
||||||
b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [
|
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_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
|
||||||
{"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}},
|
{"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}},
|
||||||
]})
|
]})
|
||||||
@ -67,7 +69,7 @@ def test_create_link_sprint_flow(live):
|
|||||||
else:
|
else:
|
||||||
agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
|
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 [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}")
|
view = client.get(f"/batches/{bid}")
|
||||||
assert view["status"] == "done" and view["remaining_seconds"] == 0
|
assert view["status"] == "done" and view["remaining_seconds"] == 0
|
||||||
assert all(c["status"] == "done" for c in view["commands"])
|
assert all(c["status"] == "done" for c in view["commands"])
|
||||||
|
|||||||
@ -96,12 +96,14 @@ def test_backup_failure_does_not_block_claim(conn, caplog):
|
|||||||
assert "не создана" in caplog.text
|
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")
|
mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import")
|
||||||
queue.create_batch(conn, "создать", "claude", [
|
queue.create_batch(conn, "создать", "claude", [
|
||||||
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}},
|
{"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_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"}},
|
{"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}},
|
||||||
])
|
])
|
||||||
c1 = queue.claim(conn)
|
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"
|
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"}}])
|
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
|
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
|
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 не перезаписываются."""
|
"""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"}}])
|
queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}])
|
||||||
c = queue.claim(conn)
|
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
|
real = queue.C.check_confirmed
|
||||||
|
|
||||||
def racing(*a, **kw):
|
def racing(*a, **kw):
|
||||||
@ -608,8 +614,9 @@ def test_attachment_params_rejected(conn, store, params, msg):
|
|||||||
assert str(e.value) == 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."""
|
"""Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size."""
|
||||||
|
add_project(conn)
|
||||||
settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}})
|
settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}})
|
||||||
b = queue.create_batch(conn, "ошибка", "claude", [
|
b = queue.create_batch(conn, "ошибка", "claude", [
|
||||||
bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")],
|
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)
|
c = queue.claim(conn, files_dir=store)
|
||||||
assert c["type"] == "ensure_attachment" and c["params"]["size"] is None
|
assert c["type"] == "ensure_attachment" and c["params"]["size"] is None
|
||||||
assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение
|
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):
|
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"):
|
with pytest.raises(CommandError, match="AURORA-1 не в проекте STOCKS_DRRK"):
|
||||||
queue.retry_command(conn, cid, "claude")
|
queue.retry_command(conn, cid, "claude")
|
||||||
assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == before
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user