Спринты и бэклог в модуле jira (#108) #109

Merged
aep merged 21 commits from feat/108-sprints-backlog into main 2026-10-25 13:56:42 +03:00
11 changed files with 53 additions and 9 deletions
Showing only changes of commit 07357732da - Show all commits

View File

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

View File

@ -307,7 +307,7 @@ make web
целиком: `{"task": {…}, "epic": {…}, "bug": {"name": "Ошибка", "id": null}}`.
Ключ в параметре может быть временным `new:<n>`: команда выдаётся после `ensure_issue` с тем же `temp_key` из того же
пакета. В хвост пакета хаб сам добавляет `refresh` по затронутым ключам. Пример пакета (`POST /batches`):
пакета. В хвост пакета хаб сам добавляет `refresh`: пакет с `ensure_sprint_meta`/`ensure_sprint_state` получает хвост по JQL проекта пакета; иначе — по затронутым ключам. Пример пакета (`POST /batches`):
```json
{"title": "1538 в спринт 2", "author": "claude", "commands": [

View File

@ -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 с ключом)

View File

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

View File

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

View File

@ -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", …) до записи."""

View File

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

View File

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

View File

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

View File

@ -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")'}

View File

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