41 KiB
Ядро и модуль 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
- Хаб стартует как процесс (
jira-hub serve,jira-hub mcp) без циклического импорта — в тестах всё уже импортировано, и цикл виден только в чистом интерпретаторе. → Task 2 (тестtest_fresh_imports). - Запись
.mcp.jsonawp-ca (jira-hub mcp --project AWPCA) поднимает тот же сервер;project_createотдаёт фрагмент.mcp.jsonс прежним путём к репозиторию (HUB_ROOTпосле переноса файла). → Task 6. - Собранное веб-приложение находится там же (
WEB_APPпосле переносаapi.py). → Task 5. - Старая база (v1…v5) мигрирует до v6 после сборки схемы из модулей. → Task 3 (существующие тесты
test_db.py+ сверка схемы с эталоном). - Отчёт агента по
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.
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):class CommandError(ValueError): ... # прежний commands.CommandError; commands реэкспортирует его class HttpMapped(Exception): # примесь: исключение модуля со своим кодом HTTP status: int = 500 error: str = "internal" -
Produces (
core/modules.py) — dataclass'ыfrozen=True; поля, не нужные модулю, — по умолчанию: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послеrefreshdone).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):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)"