From 07357732da1901cf3f7e911647cf80f08b873cfe Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:23:46 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A5=D0=B2=D0=BE=D1=81=D1=82=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=B0=20=D1=81=D0=BE=20=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=BE=D0=BC=20=E2=80=94=20refresh=20=D0=BF?= =?UTF-8?q?=D0=BE=20JQL=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20(#1?= =?UTF-8?q?08)?= 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 | 2 +- src/sdlc_hub/core/modules.py | 2 +- src/sdlc_hub/core/queue.py | 2 +- src/sdlc_hub/modules/jira/hooks.py | 8 ++++++-- src/sdlc_hub/modules/jira/projects.py | 6 ++++++ src/sdlc_hub/modules/wiki/commands.py | 2 +- tests/conftest.py | 2 +- tests/test_projects.py | 8 ++++++++ tests/test_queue.py | 26 ++++++++++++++++++++++++++ tests/test_queue_core.py | 2 +- 11 files changed, 53 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe55d9..ede38e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -78,7 +78,7 @@ - `Module(name, schema, settings_defaults, settings_obsolete, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. - Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра. -- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. +- `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail(conn, cmds, project)`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. - `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта. - `abort_result(conn, cmd)` ядро вызывает, если отчёт не дошёл до `after_result` (поздний или идемпотентный выход, исключение в транзакции): модуль снимает подготовку `before_result` и её блокировку. При успехе подготовку снимает `after_result`. diff --git a/README.md b/README.md index 1426f35..c68fe80 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ make web целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`. Ключ в параметре может быть временным `new:`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же -пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`): +пакета. В хвост пакета хаб сам добавляет `refresh`: пакет с `ensure_sprint_meta`/`ensure_sprint_state` получает хвост по JQL проекта пакета; иначе — по затронутым ключам. Пример пакета (`POST /batches`): ```json {"title": "1538 в спринт 2", "author": "claude", "commands": [ diff --git a/src/sdlc_hub/core/modules.py b/src/sdlc_hub/core/modules.py index e26f1c7..6bef970 100644 --- a/src/sdlc_hub/core/modules.py +++ b/src/sdlc_hub/core/modules.py @@ -15,7 +15,7 @@ class CommandHooks: read_types: frozenset[str] # не пишут во внешнюю систему: без хвоста и копии базы validate: Callable[[list], list[dict]] # CommandError check_batch: Callable[[Connection, list[dict], str | None, Path | None], list[dict]] # project, files_dir - tail: Callable[[list[dict]], dict | None] # хвостовая команда пишущего пакета + tail: Callable[[Connection, list[dict], str | None], dict | None] # (conn, cmds, проект пакета): хвостовая команда пишущего пакета pause: Callable[[Connection, str], int] # пауза типа, с deliver_params: Callable[[Connection, dict, Path | None], dict] temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом) diff --git a/src/sdlc_hub/core/queue.py b/src/sdlc_hub/core/queue.py index f3b7f24..44fe504 100644 --- a/src/sdlc_hub/core/queue.py +++ b/src/sdlc_hub/core/queue.py @@ -76,7 +76,7 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic h = _batch_hooks(cmds) cmds = h.validate(cmds) cmds = h.check_batch(conn, cmds, project, files_dir) - tail = h.tail(cmds) + tail = h.tail(conn, cmds, project) if tail is not None: cmds.append(tail) return insert_batch(conn, title, author, cmds, project) diff --git a/src/sdlc_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py index a84af92..6fd0af1 100644 --- a/src/sdlc_hub/modules/jira/hooks.py +++ b/src/sdlc_hub/modules/jira/hooks.py @@ -159,8 +159,12 @@ def check_batch(conn, cmds, project, files_dir) -> list[dict]: return cmds -def tail(cmds) -> dict | None: - """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат.""" +def tail(conn, cmds, project) -> dict | None: + """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат. Пакет с командами + спринта — refresh по JQL проекта пакета: у них нет ключей, а перенесённые при закрытии задачи заранее + неизвестны (design #13).""" + if project and any(c["type"] in commands.SPRINT_TYPES for c in cmds): + return {"type": "refresh", "params": {"jql": jira_projects.project_jql(conn, project)}} keys = [] for c in cmds: keys += [k for k in commands.referenced_keys(c) if k not in keys] diff --git a/src/sdlc_hub/modules/jira/projects.py b/src/sdlc_hub/modules/jira/projects.py index 06a5241..a90f458 100644 --- a/src/sdlc_hub/modules/jira/projects.py +++ b/src/sdlc_hub/modules/jira/projects.py @@ -24,6 +24,12 @@ def base_jql(conn) -> str | None: return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" +def project_jql(conn, label: str) -> str: + """JQL одного проекта-метки — в формате base_jql (тот же project_key и кавычки).""" + key = settings.get(conn, "project_key") or "AURORA" + return f'project = {key} AND labels in ("{label}")' + + def create(conn, label: str, name: str, board_id: int | None = None) -> dict: """Проект с доской: проект ядра и строка jira_project (прямой путь без POST /projects: init, миграция, тесты). Неверная доска — ProjectError("board_id", …) до записи.""" diff --git a/src/sdlc_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py index c105594..6084c97 100644 --- a/src/sdlc_hub/modules/wiki/commands.py +++ b/src/sdlc_hub/modules/wiki/commands.py @@ -94,7 +94,7 @@ def after_result(conn, cmd, status) -> None: # wiki_sync — sync.py (хуки before/abort/after только у него), wiki_fetch — attachments.py HOOKS = CommandHooks( types=frozenset(TYPES), read_types=frozenset(TYPES), validate=validate, check_batch=check_batch, - tail=lambda cmds: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], + tail=lambda conn, cmds, project: None, pause=lambda conn, ctype: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda ctype, params, mapping: params, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], check_confirmed=lambda conn, cmd: (False, None), on_claim=sync.on_claim, on_failed=on_failed, diff --git a/tests/conftest.py b/tests/conftest.py index d3ac10d..f7f8dc7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,7 +56,7 @@ def _stub_hooks() -> CommandHooks: noop = lambda *a, **k: None # noqa: E731 return CommandHooks( types=frozenset({"stub_cmd"}), read_types=frozenset({"stub_cmd"}), validate=lambda cmds: cmds, - check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda cmds: None, pause=lambda conn, t: 0, + check_batch=lambda conn, cmds, project, files_dir: cmds, tail=lambda conn, cmds, project: None, pause=lambda conn, t: 0, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda c: [], substitute=lambda t, p, mapping: p, drop_unresolved=lambda c, u: u, retry_params=lambda c: c["params"], check_confirmed=lambda conn, c: (True, None), on_claim=noop, on_failed=noop, diff --git a/tests/test_projects.py b/tests/test_projects.py index e06691c..20d018e 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -148,3 +148,11 @@ def test_ensure_legacy_skips_case_variant_and_bad_board(open_db): c2 = open_db() settings.ensure_defaults(c2) assert jira_projects.ensure_legacy(c2, "oops") is True and api.project_rows(c2, ["AWPCA"])[0]["board_id"] is None + + +def test_project_jql(open_db, add_project): + conn = open_db(); settings.ensure_defaults(conn) + add_project(conn) + assert jira_projects.project_jql(conn, "AWPCA") == 'project = AURORA AND labels in ("AWPCA")' == jira_projects.base_jql(conn) + add_project(conn, "C", board_id=None) + assert jira_projects.project_jql(conn, "C") == 'project = AURORA AND labels in ("C")' diff --git a/tests/test_queue.py b/tests/test_queue.py index 6216bfd..661d559 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1265,3 +1265,29 @@ def test_estimate_sums_module_pauses_replaces_commands_estimate(conn): через pacing -> hooks.pause -> queue.estimate.""" settings.set_(conn, "pacing", {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) assert queue.estimate(conn, ["ensure_fields", "ensure_comment", "refresh"]) == 152 + + +def test_sprint_batch_tail_is_project_jql(conn, add_project): # design #13, R-10 + add_project(conn); add_project(conn, "C", board_id=None) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}, {"key": "Y", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [meta(), {"type": "ensure_rank", "params": {"key": "X", "before": "Y"}}], project="AWPCA") + cmds = queue.batch_view(conn, b["id"])["commands"] + assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "ensure_rank", "refresh"] + assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} + + +def test_rank_batch_tail_by_keys(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [{"type": "ensure_rank", "params": {"key": "X", "after": "Z"}}], project="AWPCA") + assert queue.batch_view(conn, b["id"])["commands"][-1]["params"] == {"keys": ["X", "Z"]} + + +def test_retry_sprint_meta_gets_jql_tail(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"] + r = queue.retry_command(conn, cid, "claude") + cmds = queue.batch_view(conn, r["id"])["commands"] + assert [c["type"] for c in cmds] == ["ensure_sprint_meta", "refresh"] + assert cmds[-1]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA")'} diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py index a8cefc5..db92646 100644 --- a/tests/test_queue_core.py +++ b/tests/test_queue_core.py @@ -21,7 +21,7 @@ def make_stub(events, refs=()): hooks = CommandHooks( types=frozenset({"w", "r"}), read_types=frozenset({"r"}), validate=_validate, check_batch=lambda conn, cmds, project, files_dir: cmds, - tail=lambda cmds: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None, + tail=lambda conn, cmds, project: {"type": "r", "params": {}} if any(c["type"] == "w" for c in cmds) else None, pause=lambda conn, ctype: 7, deliver_params=lambda conn, cmd, files_dir: cmd["params"], temp_definer=None, temp_keys_used=lambda cmd: [], substitute=lambda t, p, m: p, drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"],