From c6221437b93b87679f3ec8e0f25776254b844487 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:06:43 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0:=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0,=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=81=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=BE=D0=B9=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 2 +- README.md | 4 ++- src/sdlc_hub/modules/jira/hooks.py | 46 +++++++++++++++++++++----- tests/test_queue.py | 53 ++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 491bdd2..ffe55d9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -46,7 +46,7 @@ | `commands` | контракт типов команд jira (`ensure_*`, `refresh`, `fetch_attachment`), валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | -| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint`), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | +| `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | | `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `mcp` | инструменты MCP: `issues`, `issue`, `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | diff --git a/README.md b/README.md index 934c954..1426f35 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,9 @@ make web 30 с, `refresh` 2 с (настройка `pacing`). Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): -доску определяет хаб по проекту пакета. Даты проверяются хабом: настоящая дата, начало не позже конца. +`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; +доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. `ensure_rank` в пакете проекта — `key` своей задачи. +Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index 46bf060..a84af92 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -67,15 +67,41 @@ def _assign_boards(conn, cmds) -> None: if len({p["board_id"] for p in own}) > 1: raise CommandError(f"команда {i}: {key} в проектах {', '.join(p['label'] for p in own)} с разными досками") p = own[0] - if p["board_id"] is None: - raise CommandError(f"команда {i}: у проекта {p['label']} нет доски Jira") - if p["board_type"] == "kanban": - raise CommandError(f"команда {i}: у проекта {p['label']} канбан-доска, спринтов нет") - if p["board_type"] is None: - raise CommandError(f"команда {i}: тип доски проекта {p['label']} ещё не известен — дождитесь обновления из Jira") + err = _board_error(p["label"], p["board_id"], p["board_type"]) + if err: + raise CommandError(f"команда {i}: {err}") c["params"] = {**c["params"], "board_id": p["board_id"]} +def _board_error(label, board_id, board_type) -> str | None: + """Общие тексты 422 для ensure_sprint и команд спринта: доски нет, канбан, тип ещё не известен; None — доска годится.""" + if board_id is None: + return f"у проекта {label} нет доски Jira" + if board_type == "kanban": + return f"у проекта {label} канбан-доска, спринтов нет" + if board_type is None: + return f"тип доски проекта {label} ещё не известен — дождитесь обновления из Jira" + return None + + +def _assign_project_board(conn, cmds, project) -> None: + """Доска проекта пакета в params команд спринта (ensure_sprint_meta/state): спринт не про задачу, проект — из пакета. + Новые словари params — входные не мутируются. Пакет без проекта, нет доски, канбан, тип не известен — 422.""" + board = None + for i, c in enumerate(cmds, 1): + if c["type"] not in commands.SPRINT_TYPES: + continue + if project is None: + raise CommandError(f"команда {i} ({c['type']}): команды спринта — только в пакете проекта") + if board is None: + found = next((p for p in jira_projects.light_list(conn) if p["label"] == project), None) + board = found or {"label": project, "board_id": None, "board_type": None} + err = _board_error(board["label"], board["board_id"], board["board_type"]) + if err: + raise CommandError(f"команда {i}: {err}") + c["params"] = {**c["params"], "board_id": board["board_id"]} + + def _check_project(conn, cmds, project) -> list[dict]: """Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются. Номер команды — как в присланном пакете.""" @@ -104,6 +130,8 @@ def _check_project(conn, cmds, project) -> list[dict]: elif t == "ensure_link": if not (own(p["source"]) or own(p["target"])): raise CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") + elif t in commands.SPRINT_TYPES: + pass # спринт — не задача проекта: доску ставит _assign_project_board elif t not in READ_TYPES: # fetch_attachment — не запись в Jira, проекта не касается if t == "ensure_fields": if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}: @@ -127,6 +155,7 @@ def check_batch(conn, cmds, project, files_dir) -> list[dict]: if project is not None: cmds = _check_project(conn, cmds, project) check_setup(conn, cmds, files_dir) + _assign_project_board(conn, cmds, project) return cmds @@ -249,8 +278,9 @@ def check_confirmed(conn, cmd) -> tuple[bool, dict | None]: def retry_params(cmd) -> dict: - """Повтор ensure_sprint берёт доску заново (она в params ставится при постановке).""" - return {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")} + """Повтор ensure_sprint и команд спринта берёт доску заново (она в params ставится при постановке).""" + drop = cmd["type"] in ("ensure_sprint", *commands.SPRINT_TYPES) + return {k: v for k, v in cmd["params"].items() if not (drop and k == "board_id")} def file_refs(conn) -> set[str]: diff --git a/tests/test_queue.py b/tests/test_queue.py index 779d5d0..6216bfd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -895,6 +895,59 @@ def test_retry_premigration_sprint_without_board(conn, add_project): # Review assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216} +def meta(name="AWPCA_Sprint_4"): + return {"type": "ensure_sprint_meta", "params": {"sprint": name, "start_date": "2026-10-28", "end_date": "2026-11-11"}} +def close(name="AWPCA_Sprint_3_Corrections", to="AWPCA_Sprint_4"): + return {"type": "ensure_sprint_state", "params": {"sprint": name, "state": "closed", "move_open_to": to}} + +def test_sprint_meta_gets_batch_project_board(conn, add_project): + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [meta(), close()], project="AWPCA") + cmds = queue.batch_view(conn, b["id"])["commands"] + assert cmds[0]["params"]["board_id"] == 4216 and cmds[1]["params"]["board_id"] == 4216 + +def test_sprint_meta_without_project_rejected(conn, add_project): # сценарий «Спринт без проекта пакета» + add_project(conn) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [meta()]) + assert str(e.value) == "команда 1 (ensure_sprint_meta): команды спринта — только в пакете проекта" + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + +@pytest.mark.parametrize("setup, msg", [ + (dict(board_id=5120, board_type="kanban"), "команда 1: у проекта AWPCA канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "команда 1: тип доски проекта AWPCA ещё не известен — дождитесь обновления из Jira"), + (dict(board_id=None, board_type=None), "команда 1: у проекта AWPCA нет доски Jira"), +]) +def test_sprint_state_board_rejected(conn, add_project, setup, msg): + add_project(conn, **setup) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") + assert str(e.value) == msg + +def test_rank_belongs_to_project_by_key(conn, add_project): # design #19 + add_project(conn); add_project(conn, "C", board_id=5120) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["C"]}], []) + queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") + with pytest.raises(CommandError, match="команда 1: Y не в проекте AWPCA"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "Y", "after": "X"}}], project="AWPCA") + +def test_retry_sprint_meta_recomputes_board(conn, add_project): + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [meta()], project="AWPCA") + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE jira_project SET board_id = 5120") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + +def test_retry_sprint_state_after_board_became_kanban(conn, add_project): # Review Focus 4 + add_project(conn) + b = queue.create_batch(conn, "x", "claude", [close()], project="AWPCA") + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE jira_project SET board_type = 'kanban'") + with pytest.raises(CommandError, match="канбан-доска, спринтов нет"): + queue.retry_command(conn, cid, "claude") + + def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 add_project(conn) add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None)