Команды спринта: доска проекта пакета, повтор с новой доской (#108)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 19:06:43 +04:00
parent 592b2c855c
commit c6221437b9
4 changed files with 95 additions and 10 deletions

View File

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

View File

@ -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: файл <sha> не
загружен: POST /files». Агент ищет у задачи вложение с тем же именем и размером; нет — скачивает файл у хаба

View File

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

View File

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