docs(plan): план hub-modules (#87)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 18:38:04 +04:00
parent 4a5d6dbb90
commit 05352ff5e5

View File

@ -0,0 +1,489 @@
# Ядро и модуль jira (hub-modules) — план реализации
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** разделить пакет `jira_hub` и веб-приложение на ядро и модуль `jira` с явным контрактом подключения, не меняя
внешнего поведения.
**Architecture:** Python-пакет делится на `src/jira_hub/core/` и `src/jira_hub/modules/jira/`; модуль описывается
объектом `Module` (`core/modules.py`), список модулей — `core/registry.py` (импорт модуля ленивый). Очередь, HTTP,
настройки, MCP и CLI ядра зовут обработчики модуля по типу команды или по списку модулей. Веб: `web/src/modules/jira/`
плюс описание модуля, оболочка собирает страницы и панель из `web/src/modules/index.ts`. Первая задача фиксирует
снимки внешнего поведения (golden), дальше каждый шаг обязан их сохранить.
**Tech Stack:** Python 3.13 (uv, pytest, stdlib http.server, SQLite, пакет `mcp<2`), React 19 + TypeScript + Vite,
Vitest, Playwright.
**Spec:** `openspec/changes/hub-modules/` — `design.md` (решения 1–13), `proposal.md`, `specs/mcp/spec.md`,
`specs/modules/spec.md`.
## Global Constraints
- Внешнее поведение не меняется: адреса, коды, тела ответов HTTP API; инструменты MCP (имена, описания, схемы
параметров, тексты ответов); адреса веба `/app/board`, `/app/issues`, `/app/feed`, `/app/issues/{key}`; ключи
`localStorage` (`jira-hub-sidebar`, ключи доски); протокол агента `/agent/*`.
- Схема базы остаётся v6 (`SCHEMA_VERSION = 6`), таблицы не переименовываются, файлы в каталоге данных не переносятся.
- Имя пакета `jira_hub`, точка входа `jira-hub`, имя MCP-сервера `jira-hub`, каталог сборки веба `src/jira_hub/web/app/`
— не меняются.
- Ядро (`src/jira_hub/core/`) не импортирует `jira_hub.modules.*`, кроме `core/registry.py` (ленивый импорт внутри
функции) и шагов истории миграций в `core/db.py` (ленивый импорт внутри шага).
- Новые имена в ядре — без «jira».
- Тесты меняют только импорты, обращения к перенесённым именам и места подстановки зависимостей (`monkeypatch` по
имени модуля, атрибуты `Hub`);
утверждения (`assert`) существующих тестов не меняются. Эталоны `tests/fixtures/baseline/*.expected.json` после
Task 1 не меняются.
- Каждая задача заканчивается зелёным `uv run pytest -q` (весь набор); задачи, трогающие `web/`, — ещё `make check`.
- Места долга (design, решение 12) помечаются комментарием `# долг hub-modules: проекты → модуль jira (change wiki)`
(в TS — `// долг hub-modules: …`).
- Язык комментариев и коммитов — русский; коммит заканчивается строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Хаб стартует как процесс (`jira-hub serve`, `jira-hub mcp`) без циклического импорта — в тестах всё уже импортировано,
и цикл виден только в чистом интерпретаторе. → Task 2 (тест `test_fresh_imports`).
2. Запись `.mcp.json` awp-ca (`jira-hub mcp --project AWPCA`) поднимает тот же сервер; `project_create` отдаёт фрагмент
`.mcp.json` с прежним путём к репозиторию (`HUB_ROOT` после переноса файла). → Task 6.
3. Собранное веб-приложение находится там же (`WEB_APP` после переноса `api.py`). → Task 5.
4. Старая база (v1…v5) мигрирует до v6 после сборки схемы из модулей. → Task 3 (существующие тесты `test_db.py` +
сверка схемы с эталоном).
5. Отчёт агента по `refresh`/`fetch_attachment`/`ensure_issue` (в том числе поздний — после отмены) даёт те же записи в
`sync_run`, `attachment_file`, заметках и тот же `reconcile`. → Task 4 (существующие тесты `test_queue.py`).
---
## Структура файлов (итог)
```
src/jira_hub/
__init__.py
cli.py # тонкая точка входа: from .core.cli import main (pyproject не меняется)
web/app/ # сборка (не в git), место прежнее
core/
__init__.py
paths.py backup.py files.py relay.py release.py client.py # перенос как есть (files.sweep — решение 12)
errors.py # CommandError, HttpMapped
modules.py # Module, CommandHooks, Route
registry.py # modules(), module_of(), command_types(), check()
db.py # ядро схемы + сборка schema(), история MIGRATIONS
settings.py # ключи ядра + объединение с модулями
projects.py # метка, название, проверка, список (+ долг: board_id, issues_count)
queue.py # механика пакетов
http.py # ApiError, Raw, общие помощники обработчиков
api.py # Hub, Handler, маршруты ядра, tick
mcp.py # client, guard, scoped, projects/project_create, run()
cli.py # main(): init, serve, tokens, mcp, agent publish + подкоманды модулей
modules/
__init__.py
jira/
__init__.py # MODULE = Module(...)
schema.py # SQL таблиц модуля
settings.py # умолчания, проверка, ключи агента, init_from_config
commands.py # перенос (SPEC, validate, substitute, check_confirmed, estimate → pause)
hooks.py # CommandHooks модуля: check_batch, tail, deliver_params, on_claim, …
projects.py # base_jql, boards, record_board_types, parse_legacy_labels, ensure_legacy, migrate_v3
mirror.py history.py attachments.py importer.py # перенос
fetch.py # request_fetch, TooLarge, AgentTooOld, FETCH_AGENT_VERSION
refresh.py # is_full_refresh, record_state, maybe_auto_refresh (tick)
api.py # маршруты модуля
mcp.py # mcp = FastMCP("jira-hub") и инструменты модуля
cli.py # подкоманда import
web/src/
modules/index.ts # export const MODULES: HubModule[] = [jiraModule]
modules/types.ts # тип HubModule
modules/jira/
index.tsx # jiraModule
board/ issues/ feed/ # перенос
refresh/ # refresh.ts, RefreshProvider.tsx, refreshStatus.ts (+ тесты), DataStatus.tsx
api/queries.ts # запросы модуля из api/queries.ts
```
---
### Task 1: Эталоны внешнего поведения
**Files:**
- Create: `tests/test_baseline.py`, `tests/fixtures/baseline/{mcp_tools,routes,settings,schema}.expected.json`
**Interfaces:**
- Produces: тест `tests/test_baseline.py`, который в каждой следующей задаче меняет только строки импорта.
- [ ] **Step 1: Написать тест со сбором снимков**
Четыре теста, каждый сравнивает снимок с `tests/fixtures/baseline/<имя>.expected.json`; при переменной окружения
`BASELINE_WRITE=1` — пишет эталон (так же, как устроены golden-тесты проекта: `tests/fixtures/<name>.expected.*`).
- `test_mcp_tools`: `asyncio.run(mcp_server.mcp.list_tools())` → список `{name, description, inputSchema}` в порядке
регистрации.
- `test_routes`: `api.ROUTES` → список `[method, pattern, scope, handler.__name__]`, отсортированный по
`(pattern, method)` (порядок может смениться при сборке из модулей; набор и сопоставление — нет).
- `test_settings`: `{"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS, "protected": sorted(settings.PROTECTED)}`.
- `test_schema`: свежая база в `tmp_path` через `db.connect` → `SELECT type, name, sql FROM sqlite_master WHERE name NOT
LIKE 'sqlite_%' ORDER BY type, name` и `schema_version`.
- [ ] **Step 2: Записать эталоны и проверить**
Run: `BASELINE_WRITE=1 uv run pytest tests/test_baseline.py -q && uv run pytest tests/test_baseline.py -q`
Expected: 4 passed (второй прогон — сравнение с записанным).
- [ ] **Step 3: Весь набор зелёный, коммит**
Run: `uv run pytest -q` — Expected: всё passed.
```bash
git add tests/test_baseline.py tests/fixtures/baseline
git commit -m "test: эталоны внешнего поведения перед перестройкой (#87)"
```
---
### Task 2: Пакеты core и modules.jira — перенос файлов без логики
**Files:**
- Move (git mv): `paths.py backup.py files.py relay.py release.py client.py` → `src/jira_hub/core/`;
`mirror.py history.py attachments.py commands.py importer.py` → `src/jira_hub/modules/jira/`;
`db.py settings.py projects.py queue.py api.py mcp_server.py cli.py` → `src/jira_hub/core/` (временно целиком —
разделяются в Task 3–7).
- Create: `src/jira_hub/core/__init__.py`, `src/jira_hub/modules/__init__.py`, `src/jira_hub/modules/jira/__init__.py`
(пустые), `src/jira_hub/cli.py` (`from .core.cli import main`), `tests/test_layout.py`.
- Modify: импорты во всём `src/`, `tests/`, `web/scripts/seed-hub.py`; `api.WEB_APP`, `mcp_server.HUB_ROOT` — путь
от нового места файла.
**Interfaces:**
- Produces: модули по новым путям (`jira_hub.core.queue`, `jira_hub.modules.jira.mirror` …). `pyproject.toml`
(`jira-hub = "jira_hub.cli:main"`) не меняется.
- [ ] **Step 1: Тест на чистый импорт и пути**
`tests/test_layout.py`:
- `test_fresh_imports`: `subprocess.run([sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"])`
→ `returncode == 0` (в Task 5–6 имя `core.mcp_server` заменится на `core.mcp` и `modules.jira.mcp`).
- `test_web_app_path`: `api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app"`.
- `test_hub_root`: `mcp_server.HUB_ROOT` — каталог, где лежит `pyproject.toml` проекта.
- [ ] **Step 2: Убедиться, что тест падает** — Run: `uv run pytest tests/test_layout.py -q` — Expected: FAIL (нет `jira_hub.core`).
- [ ] **Step 3: Перенести файлы `git mv`, поправить импорты**
Относительные импорты: из `core/*` к соседям — `from . import …`, к модулю jira — `from ..modules.jira import …` (это
временно, такие импорты уберут Task 3–7). Из `modules/jira/*` — `from ...core import db, settings, …`. Поиск остатков:
`grep -rn "jira_hub\.\(db\|queue\|mirror\|api\|settings\|projects\|history\|files\|commands\|attachments\|mcp_server\|relay\|release\|client\|paths\|backup\|importer\)\b" src tests web/scripts`
— пусто. `monkeypatch.setattr("jira_hub.X…")` в тестах — на новые пути.
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed, включая `test_baseline.py` и `test_layout.py`.
- [ ] **Step 5: Коммит** — `git commit -m "refactor: пакеты core и modules.jira — перенос без логики (#87)"`
---
### Task 3: Контракт модуля, реестр, схема и настройки
**Files:**
- Create: `core/errors.py`, `core/modules.py`, `core/registry.py`, `modules/jira/schema.py`, `modules/jira/settings.py`,
`modules/jira/projects.py`, `tests/test_registry.py`
- Modify: `core/db.py`, `core/settings.py`, `core/projects.py`, `modules/jira/__init__.py`, `modules/jira/commands.py`
(`CommandError` — из `core.errors`), `core/cli.py` (`init`), `tests/test_baseline.py` (импорты)
**Interfaces:**
- Produces (`core/errors.py`):
```python
class CommandError(ValueError): ... # прежний commands.CommandError; commands реэкспортирует его
class HttpMapped(Exception): # примесь: исключение модуля со своим кодом HTTP
status: int = 500
error: str = "internal"
```
- Produces (`core/modules.py`) — dataclass'ы `frozen=True`; поля, не нужные модулю, — по умолчанию:
```python
Route = tuple[str, str, Callable, str | None] # method, regex, handler(hub, conn, m, q, body), scope
@dataclass(frozen=True)
class CommandHooks:
types: frozenset[str]
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] # хвостовая команда пишущего пакета
pause: Callable[[Connection, str], int] # пауза типа, с
deliver_params: Callable[[Connection, dict, Path | None], dict]
temp_definer: tuple[str, str, str] | None # (тип, поле params с временным ключом, поле result с ключом)
temp_keys_used: Callable[[dict], list[str]]
substitute: Callable[[str, dict, dict], dict]
drop_unresolved: Callable[[dict, list[str]], list[str]] # поправить params команды, вернуть неразрешённые
retry_params: Callable[[dict], dict]
check_confirmed: Callable[[Connection, dict], tuple[bool, dict | None]]
on_claim: Callable[[Connection, dict, str, bool], None] # cmd, now, redelivery
on_failed: Callable[[Connection, dict, str], None] # исчерпаны выдачи
check_result: Callable[[Connection, dict, str, dict | None, Path | None], tuple[str, str | None]]
on_result: Callable[[Connection, dict, str, dict | None, int, int, str, Path | None], None] # в транзакции
after_result: Callable[[Connection, dict, str], None] # вне транзакции
on_late_result: Callable[[Connection, dict, str, dict | None, int, int], bool]
@dataclass(frozen=True)
class Module:
name: str
schema: str = ""
settings_defaults: dict = field(default_factory=dict)
agent_keys: tuple[str, ...] = ()
validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо)
settings_view: Callable[[Connection], dict] | None = None # вычисляемые поля GET/PUT /settings
on_init: Callable[[Connection, dict, bool], list[str]] | None = None # init --config: строки для вывода
commands: CommandHooks | None = None
routes: tuple[Route, ...] = ()
tick: tuple[Callable[[Any, Connection], None], ...] = () # (hub, conn)
file_refs: Callable[[Connection], set[str]] | None = None # sha файлов, нужных командам
on_project_created: Callable[[Connection, dict, str], dict | None] | None = None # project, author → пакет
mcp_server: Callable[[], Any] | None = None # FastMCP модуля
cli_register: Callable[[Any], None] | None = None # subparsers
cli_run: Callable[[Any], int | None] | None = None # None — не своя подкоманда
```
- Produces (`core/registry.py`): `_cache: list[Module] | None = None`; `modules() -> list[Module]` (при `_cache is None`
— импорт `modules.jira` внутри функции и `check`, результат в `_cache`; тесты подменяют `_cache`);
`module(name) -> Module | None`; `module_of(ctype: str) -> Module | None`; `check(mods: list[Module], core_routes,
core_settings: dict) -> None` — `RuntimeError` с текстом конфликта: тип команды «тип refresh: модули jira и x»,
ключ настроек, маршрут (метод + шаблон).
- Produces (`core/db.py`): `CORE_SCHEMA` (`setting`, `project`, `batch`, `command`, `agent_status`); `schema() -> str`
= `CORE_SCHEMA` + `schema` модулей по порядку; `connect`/`migrate` используют `schema()`. `MIGRATIONS` — на месте;
шаги, зовущие код модуля, импортируют его внутри шага. `jira_utc` и `_fill_comment_utc` — в
`modules/jira/mirror.py` (или `history.py`, где используется), шаг v4→v5 зовёт их лениво.
- Produces (`core/settings.py`): `CORE_DEFAULTS` (`backup_keep`, `min_agent_version`, `port`, `settings_version`,
`initialized`, `relay_dir`), `defaults() -> dict` (ядро + модули), `agent_keys() -> list[str]`; `validate` — сначала
ключи ядра, затем `validate_setting` модулей, затем проверка по типу умолчания из `defaults()`; `get_all`,
`ensure_defaults`, `for_agent` — по объединённому набору. `PROTECTED` — в ядре. Константы `DEFAULTS`/`AGENT_KEYS`
остаются как вычисляемые значения для обратной совместимости тестов? — **нет**: тесты и эталон зовут `defaults()` и
`agent_keys()` (меняется только вызов в `test_baseline.py`, эталон тот же).
- Produces (`modules/jira/settings.py`): `DEFAULTS` (остальные ключи, включая `pacing`, `auto_refresh_minutes`,
`project_key`, `closed_statuses`), `AGENT_KEYS`, `DEFAULT_PACING`, `pacing(conn)`, `validate_setting` (`base_jql`,
`board_id` — отказ с прежними текстами, `pacing`, `auto_refresh_minutes`), `settings_view(conn) -> {"base_jql": …}`,
`on_init(conn, cfg, force) -> list[str]` — прежние `settings.init_from_config` + `projects.ensure_legacy` и строки
вывода (`перенесено: …`, `проект AWPCA «АРМ КА», доска N`).
- Produces (`modules/jira/projects.py`): `base_jql`, `boards`, `record_board_types`, `parse_legacy_labels`,
`ensure_legacy`, `migrate_v3`, `LEGACY_LABEL`, `LEGACY_NAME`. В `core/projects.py` остаются `validate`, `get`,
`create`, `list_projects`, `light_list`, `labels`, `ProjectError`, `ProjectExists`; `board_id`/`board_type`/
`issues_count` в них — с меткой долга.
- `modules/jira/__init__.py`: `MODULE = Module(name="jira", schema=schema.SQL, settings_defaults=settings.DEFAULTS,
agent_keys=tuple(settings.AGENT_KEYS), validate_setting=…, settings_view=…, on_init=…)` — остальные поля добавляют
Task 4–6.
- [ ] **Step 1: Тесты реестра** — `tests/test_registry.py`:
- `test_jira_registered`: `[m.name for m in registry.modules()] == ["jira"]`.
- `test_command_type_conflict`: два `Module` с `CommandHooks(types=frozenset({"refresh"}), …)` → `check` бросает
`RuntimeError`, в тексте `refresh`, `jira`, имя второго модуля.
- `test_settings_conflict`: модуль с `settings_defaults={"port": 1}` → `RuntimeError` с `port`.
- `test_route_conflict`: модуль с маршрутом `("GET", r"^/projects$", h, "client")` → `RuntimeError` с `/projects`.
- `test_core_imports`: AST всех `src/jira_hub/core/*.py`, кроме `registry.py` и `db.py`, — нет импортов
`modules`/`jira_hub.modules`; в `registry.py` и `db.py` такие импорты только внутри функций.
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_registry.py -q` — Expected: FAIL (нет `registry`).
- [ ] **Step 3: Реализовать `errors`, `modules`, `registry`, разделить `db`, `settings`, `projects`; `init` в
`core/cli.py` зовёт `on_init` модулей и печатает их строки в прежнем порядке, затем токены.** Эталон схемы требует
тот же набор таблиц и тот же SQL: порядок `CREATE` внутри `sqlite_master` сравнивается по имени, поэтому порядок
сборки на эталон не влияет, а текст SQL таблиц переносится символ в символ.
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (`test_db.py` — миграции v1…v5,
`test_settings.py`, `test_projects.py`, `test_cli.py`, эталоны).
- [ ] **Step 5: Коммит** — `git commit -m "refactor: контракт модуля, реестр, схема и настройки по модулям (#87)"`
---
### Task 4: Очередь — механика в ядре, смысл типов в модуле
**Files:**
- Create: `modules/jira/hooks.py`, `modules/jira/fetch.py`, `modules/jira/refresh.py`, `tests/test_queue_core.py`
- Modify: `core/queue.py`, `core/files.py` (`sweep` берёт ссылки из `file_refs` модулей), `modules/jira/commands.py`
(`estimate` уходит, пауза — `hooks.pause`), `modules/jira/__init__.py` (`commands=hooks.HOOKS`,
`file_refs=hooks.file_refs`), `tests/test_queue.py`, `tests/test_files.py` (импорты: `request_fetch`, `TooLarge`,
`AgentTooOld`, `maybe_auto_refresh`, `is_full_refresh`, `record_state` — из модуля jira)
**Interfaces:**
- Consumes: `CommandHooks`, `registry.module_of`, `registry.modules` (Task 3).
- Produces (`core/queue.py`, публичное): `create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dict`
(прежняя сигнатура), `insert_batch(conn, title, author, cmds, project=None) -> dict` (бывший `_insert_batch`),
`claim`, `report_result`, `reconcile`, `cancel_batch`, `cancel_command`, `retry_command`, `batch_view`,
`list_batches`, `get_command`, `agent_view`, `deliver_params(conn, cmd, files_dir) -> dict` (делегирует модулю),
`agent_version(conn) -> str | None`, `has_active(conn) -> bool` (есть `queued`/`claimed`), `estimate(conn, types) ->
int` (сумма `pause` модулей), `TERMINAL`, `MAX_DELIVERIES`, `ERROR_LIMIT`, `ONLINE_MINUTES`, `ClaimedError`,
`NotClaimedError`.
- Produces (`modules/jira/fetch.py`): `TooLarge(CommandError, HttpMapped)` с `status=413`, `error="too_large"`;
`AgentTooOld(ValueError, HttpMapped)` с `status=409`, `error="agent_too_old"`; `FETCH_AGENT_VERSION = 14`;
`request_fetch(conn, files_dir, attachment_id, author) -> dict` (прежнее поведение, через `queue.insert_batch`,
`queue.agent_version`).
- Produces (`modules/jira/refresh.py`): `is_full_refresh(conn, command_id) -> bool`, `record_state(conn, command_id,
issues_count, links_count)`, `maybe_auto_refresh(conn) -> dict | None` (через `queue.agent_view`, `queue.has_active`,
`queue.create_batch`), `auto_refresh_tick(hub, conn) -> None`.
- Правила ядра:
- Модуль пакета: модули известных типов команд; больше одного — `CommandError("команды разных модулей в одном
пакете")`; ни одного — `validate` первого модуля реестра (он выдаёт прежнюю ошибку о неизвестном типе).
- `create_batch`: `validate` → `check_batch(conn, cmds, project, files_dir)` (внутри модуля прежний порядок:
`_check_project`, если `project` задан, затем `_check_setup`, затем `_assign_boards`) → `tail(cmds)` → `insert_batch`.
- «Пакет с записями» в `claim` — есть команда, чей тип не в `read_types` своего модуля (SQL с типами из реестра,
а не литералами).
- Временные ключи: `_mapping`/`_definer_status` строятся по `temp_definer` модуля; `_next` зовёт `temp_keys_used`,
`substitute`, `drop_unresolved` (у jira: `refresh` выбрасывает временные ключи из `keys`, если остались ключи —
неразрешённых нет).
- `report_result`: терминальная команда → `on_late_result`; иначе `check_result` (может сменить статус на `failed`
и ошибку) → запись команды и `agent_status` → `on_result` (в той же транзакции) → после транзакции
`after_result` (jira: `reconcile` после `refresh` `done`).
- `files.sweep(conn, root, now=None)` — защищённые файлы = объединение `file_refs(conn)` модулей.
- `retry_command` берёт `params` через `retry_params` (jira: без `board_id` у `ensure_sprint`).
- [ ] **Step 1: Тест ядра очереди на модуле-заглушке** — `tests/test_queue_core.py`: модуль `"t"` с типами `{"w", "r"}`
(`r` — `read_types`), `pause` = 7, `tail` возвращает `{"type": "r", "params": {}}` для пакета с `w`; реестр
подменяется через `monkeypatch.setattr(registry, "_cache", [jira.MODULE, stub])`.
- `test_tail_and_estimate`: пакет `[w]` → две команды `w`, `r`; `estimate_seconds == 14`.
- `test_mixed_modules`: пакет `[w, refresh]` → `CommandError`, текст «команды разных модулей в одном пакете».
- `test_on_result_called`: `claim` → `report_result(done)` → заглушка `on_result` получила `status == "done"`,
`after_result` вызван после фиксации (видит команду `done` в новом соединении).
- `test_sweep_uses_file_refs`: файл старше 30 дней, `file_refs` заглушки возвращает его sha → не удалён.
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_queue_core.py -q` — Expected: FAIL.
- [ ] **Step 3: Перенести логику типов в `modules/jira/hooks.py`, `fetch.py`, `refresh.py`; `core/queue.py` — без
`'refresh'`, `'ensure_*'`, `'fetch_attachment'`, `sync_run`, `issue_attachment`, `attachment_file`; без импортов
`mirror`, `history`, `attachments`, `projects`-модуля.** Тексты ошибок и порядок проверок — прежние (их проверяет
`test_queue.py`).
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (99 тестов `test_queue.py` без изменения
утверждений).
- [ ] **Step 5: Коммит** — `git commit -m "refactor: очередь ядра и обработчики команд модуля jira (#87)"`
---
### Task 5: HTTP — сервер ядра, маршруты модуля
**Files:**
- Create: `core/http.py`, `modules/jira/api.py`
- Modify: `core/api.py`, `modules/jira/__init__.py` (`routes`, `tick`, `on_project_created`), `tests/test_api.py`,
`tests/test_web_app.py`, `tests/test_baseline.py`, `tests/test_layout.py` (импорты; `hub.downloads_dir` — см. ниже)
**Interfaces:**
- Produces (`core/http.py`): `ApiError(status, error, detail=None)`, `Raw(content_type, data, headers=None)`,
`int_group(m, i=1) -> int` (бывший `_int`), `int_param(q, name, default) -> int`, `not_found(obj, what)`,
`project_param(conn, q) -> str | None`, `mapped(e: HttpMapped) -> ApiError`.
- Produces (`core/api.py`): `Hub` (без `downloads_dir`; новое `hub.module_state: dict[str, dict]` — память модулей
между тиками), `CORE_ROUTES`, `ROUTES` (вычисляется при импорте: `CORE_ROUTES` + `routes` модулей, после
`registry.check`), `make_server`, `start`, `serve`, `WEB_APP` (путь прежний — `src/jira_hub/web/app`).
`Hub.tick`: `sweep_files` ядра, затем `tick` модулей; сбой каждой работы логируется и не мешает остальным.
Обработчики ядра, ловившие `queue.TooLarge`/`AgentTooOld`, ловят `HttpMapped` раньше `CommandError`.
`GET`/`PUT /settings` дописывают `settings_view` модулей. `POST /projects`: в одной транзакции `projects.create` и
`on_project_created` модулей; `batch_id` ответа — из первого вернувшего пакет.
- Produces (`modules/jira/api.py`): обработчики и `ROUTES` модуля: `/agent/state`, `/agent/history`,
`/agent/known-keys`, `/issues`, `/issues/{key}`, `/issues/{key}/note`, `/board`, `/changes`, `/attachments/…` (три),
`/refresh`, `/sync-runs`. `downloads_tick(hub, conn)` — уборка `downloads/` (`<каталог базы>/downloads`) раз в
сутки, время прошлой — `hub.module_state["jira"]["downloads_at"]`; `on_project_created(conn, project, author)` —
пакет «Новый проект <метка>».
- Маршруты ядра — все остальные из нынешней `ROUTES` (design, решение 6).
- [ ] **Step 1: Тест сборки маршрутов** — в `tests/test_registry.py`: `test_routes_assembled`:
`{(m, p) for m, p, _, _ in api.ROUTES} == {(m, p) for m, p, _, _ in api.CORE_ROUTES} | {(m, p) for m, p, _, _ in jira.MODULE.routes}`
и пересечения нет. Эталон `routes.expected.json` — тот же (в `test_baseline.py` меняется только импорт).
- [ ] **Step 2: Тест падает** — Run: `uv run pytest tests/test_registry.py::test_routes_assembled -q` — Expected: FAIL.
- [ ] **Step 3: Разделить `core/api.py`; тесты, читавшие `hub.downloads_dir`, берут путь `hub.db_path.parent / "downloads"`.**
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (`test_api.py`, `test_web_app.py`,
`test_e2e_hub.py`, `test_layout.py::test_web_app_path`, эталоны).
- [ ] **Step 5: Коммит** — `git commit -m "refactor: HTTP — сервер ядра, маршруты модуля jira (#87)"`
---
### Task 6: MCP и CLI по модулям
**Files:**
- Create: `core/mcp.py`, `modules/jira/mcp.py` (из `core/mcp_server.py`), `modules/jira/cli.py`
- Delete: `core/mcp_server.py`
- Modify: `core/cli.py`, `modules/jira/__init__.py` (`mcp_server`, `cli_register`, `cli_run`), `tests/test_mcp.py`,
`tests/test_cli.py`, `tests/test_baseline.py`, `tests/test_layout.py` (импорты)
**Interfaces:**
- Produces (`core/mcp.py`): `HUB_ROOT` (каталог с `pyproject.toml`, как сейчас), `mcp_json(label) -> dict`,
`unknown_project`, `guard`, `scoped`, `client()`, `bound_project`, `fmt_projects`, функции инструментов `projects()`,
`project_create(label, name, board_id=None)` (долг: `board_id`), `register_core_tools(mcp) -> None`,
`run(module_name: str, project: str | None) -> int` — неизвестный модуль: печать в stderr
`неизвестный модуль <имя>; модули: jira`, код 2, сервер не запускается.
- Produces (`modules/jira/mcp.py`): `mcp = FastMCP("jira-hub")`, инструменты модуля на нём в прежнем порядке, затем
`register_core_tools(mcp)` — `projects`, `project_create` последними, как сейчас.
- CLI: `jira-hub mcp [module] [--project <метка>]` — позиционный `module` с `nargs="?"`, `default="jira"`, без
`choices` (проверка в `run`, текст из спеки). `import` — подкоманда модуля (`cli_register`/`cli_run`).
`agent publish`, `init`, `serve`, `tokens` — ядро.
- [ ] **Step 1: Тесты** — в `tests/test_cli.py`:
- `test_mcp_default_module`: `main(["mcp", "--project", "AWPCA"])` и `main(["mcp", "jira", "--project", "AWPCA"])`
при подменённом `mcp.run` (как в `test_mcp.py`) — оба запускают объект `modules.jira.mcp.mcp`, привязка — `AWPCA`.
- `test_mcp_unknown_module`: `main(["mcp", "nope"]) == 2`, в stderr `неизвестный модуль nope` и `jira`.
- [ ] **Step 2: Тесты падают** — Run: `uv run pytest tests/test_cli.py -q` — Expected: FAIL.
- [ ] **Step 3: Разделить MCP и CLI.**
- [ ] **Step 4: Весь набор** — Run: `uv run pytest -q` — Expected: всё passed (эталон `mcp_tools.expected.json` тот же,
`test_layout.py::test_hub_root`, `test_fresh_imports` с `jira_hub.core.mcp, jira_hub.modules.jira.mcp`).
- [ ] **Step 5: Ручная проверка запуска** — Run: `printf '' | timeout 5 uv run jira-hub mcp nope; echo $?` —
Expected: строка `неизвестный модуль nope; модули: jira` и `2`.
- [ ] **Step 6: Коммит** — `git commit -m "refactor: MCP и CLI по модулям; jira-hub mcp [модуль] (#87)"`
---
### Task 7: Веб — модуль jira и сборка оболочки из модулей
**Files:**
- Move (git mv): `web/src/{board,issues,feed}` → `web/src/modules/jira/`; `web/src/shell/{refresh.ts,refresh.test.tsx,RefreshProvider.tsx,refreshStatus.ts,refreshStatus.test.ts,DataStatus.tsx}` → `web/src/modules/jira/refresh/`
- Create: `web/src/modules/types.ts`, `web/src/modules/index.ts`, `web/src/modules/jira/index.tsx`,
`web/src/modules/jira/api/queries.ts`, `web/src/modules/jira/issues/overlay.tsx`, `web/src/modules/modules.test.tsx`
- Modify: `web/src/App.tsx`, `web/src/shell/{Layout,Sidebar,pages}.tsx`, `web/src/api/queries.ts` (остаются запросы
ядра: проекты, настройки, пакеты), `web/src/projects/NewProjectDialog.tsx` (метка долга у поля доски),
`web/scripts/{test-hub.sh,golden-from-links.ts}`, `web/e2e/board.spec.ts`, `web/src/test/*` (пути)
**Interfaces:**
- Produces (`web/src/modules/types.ts`):
```ts
export type NavItem = { to: string; label: string; icon: React.ReactNode };
export type Overlay = { pageLocation: Location | null; element: React.ReactNode }; // null — окна нет
export type HubModule = {
name: string;
pages: RouteObject[]; // без "*" — NotFound добавляет оболочка последним
nav: NavItem[];
Provider?: React.ComponentType<{ children: React.ReactNode }>;
SidebarStatus?: React.ComponentType; // плашка данных и кнопка обновления в боковой панели
useOverlay?: () => Overlay; // окно поверх страницы (карточка задачи)
useOnProjectCreated?: () => (batchId: number) => void;
};
```
- Produces (`web/src/modules/jira/index.tsx`): `jiraModule: HubModule` — `pages`: `/` → `/board`, `/board`, `/issues`,
`/feed` (как в нынешнем `shell/pages.tsx`); `nav`: «Доска связей», «Задачи», «Лента» с прежними значками;
`Provider`: `RefreshProvider`; `SidebarStatus`: `DataStatus` + кнопка обновления с событием пакета (то, что сейчас
рисует `Sidebar` через `useJiraRefresh`/`refreshText`); `useOverlay`: логика окна карточки из `Layout.tsx`
(`issueKey`, `cardState`, `bgLocation`, `close`, `abortClose`, `CloseAbortContext`, `IssueModal`);
`useOnProjectCreated`: `refresh.track(batchId)`.
- Оболочка: `App.tsx` вкладывает `Provider` всех модулей; `pages.tsx` — `PAGE_ROUTES` = страницы модулей + `NotFound`;
`Sidebar.tsx` — `nav` модулей (тип `Section` становится `string`), `SidebarStatus` модулей; `Layout.tsx` — первый
`useOverlay`, вернувший `pageLocation`, определяет адрес страницы и окно. Хуки модулей вызываются в постоянном
порядке (список модулей — константа).
- [ ] **Step 1: Тест сборки** — `web/src/modules/modules.test.tsx`:
- пути `PAGE_ROUTES` == `["/", "/board", "/issues", "/feed", "*"]`;
- пункты панели (подписи, адреса) == «Доска связей» `/board`, «Задачи» `/issues`, «Лента» `/feed`;
- имена модулей уникальны.
- [ ] **Step 2: Тест падает** — Run: `cd web && npx vitest run src/modules/modules.test.tsx` — Expected: FAIL.
- [ ] **Step 3: Перенести папки `git mv`, вынести описание модуля, переписать оболочку.** Поиск остатков:
`grep -rn "\.\./board\|\.\./issues\|\.\./feed\|shell/refresh\|shell/DataStatus\|src/board" web/src web/scripts web/e2e`
— только новые пути.
- [ ] **Step 4: Проверки** — Run: `make check` — Expected: pytest, lint, vitest — всё passed (golden доски — без
изменения эталонов, `Sidebar.test.tsx`, `IssueModal.test.tsx`, `projects.test.tsx` — только импорты).
- [ ] **Step 5: e2e** — Run: `cd web && npm run e2e` — Expected: всё passed.
- [ ] **Step 6: Коммит** — `git commit -m "refactor(web): модуль jira и оболочка из списка модулей (#87)"`
---
### Task 8: Граница ядра, метки долга, документация
**Files:**
- Modify: `tests/test_registry.py`, места долга в `core/projects.py`, `core/mcp.py`, `core/api.py` (если
`issues_count`/`board_id` проходят через него), `web/src/projects/NewProjectDialog.tsx`; `ARCHITECTURE.md`,
`CLAUDE.md`, `README.md` (пути модулей, команда `jira-hub mcp [модуль]`)
**Interfaces:**
- Consumes: всё выше.
- [ ] **Step 1: Тест границы** — `test_core_has_no_jira_terms` в `tests/test_registry.py`: в файлах
`src/jira_hub/core/*.py`, кроме `db.py` (история `MIGRATIONS`), строки с любым из `'refresh'`, `"refresh"`,
`ensure_`, `fetch_attachment`, `sync_run`, `issue_attachment`, `attachment_file`, `issue_comment`, `jql` допустимы
только если в той же или предыдущей строке есть `долг hub-modules`. Падение показывает файл и строку.
- [ ] **Step 2: Прогнать** — Run: `uv run pytest tests/test_registry.py -q` — Expected: PASS; если FAIL — перенести
найденное в модуль (не помечать долгом ничего, кроме перечисленного в design, решение 12).
- [ ] **Step 3: Метки долга на месте** — Run: `grep -rn "долг hub-modules" src web/src` — Expected: места из
design (решение 12): `core/projects.py` (`board_id`/`board_type`, проверка `board_id`, `issues_count`),
`core/mcp.py` (`project_create.board_id`, `fmt_projects`), `NewProjectDialog.tsx` (поле доски).
- [ ] **Step 4: Документация** — `ARCHITECTURE.md`: таблица модулей ядра и модуля jira с новыми путями, контракт
`Module`/`CommandHooks`, раздел «Границы» (ядро не импортирует модули; типы команд — в модулях; долг проектов),
цепочки зависимостей; веб — `web/src/modules/`. `CLAUDE.md`: раздел «Архитектура» и команды (`jira-hub mcp [модуль]
[--project]`). Переименование в sdlc-hub — строка в «Открытых вопросах» `ARCHITECTURE.md`.
- [ ] **Step 5: Полная проверка** — Run: `make check && (cd web && npm run e2e)` — Expected: всё passed.
- [ ] **Step 6: Коммит** — `git commit -m "docs: ядро и модули хаба; тест границы ядра (#87)"`