From 4a5d6dbb90ed61c8d5691017901a106c43d8e2b0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:31:31 +0400 Subject: [PATCH 01/23] =?UTF-8?q?docs(openspec):=20change=20hub-modules=20?= =?UTF-8?q?=E2=80=94=20=D1=8F=D0=B4=D1=80=D0=BE=20=D0=B8=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8C=20jira=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/.openspec.yaml | 2 + openspec/changes/hub-modules/design.md | 183 ++++++++++++++++++ openspec/changes/hub-modules/proposal.md | 48 +++++ .../changes/hub-modules/specs/mcp/spec.md | 22 +++ .../changes/hub-modules/specs/modules/spec.md | 23 +++ 5 files changed, 278 insertions(+) create mode 100644 openspec/changes/hub-modules/.openspec.yaml create mode 100644 openspec/changes/hub-modules/design.md create mode 100644 openspec/changes/hub-modules/proposal.md create mode 100644 openspec/changes/hub-modules/specs/mcp/spec.md create mode 100644 openspec/changes/hub-modules/specs/modules/spec.md diff --git a/openspec/changes/hub-modules/.openspec.yaml b/openspec/changes/hub-modules/.openspec.yaml new file mode 100644 index 0000000..f56fe13 --- /dev/null +++ b/openspec/changes/hub-modules/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-23 diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md new file mode 100644 index 0000000..9833405 --- /dev/null +++ b/openspec/changes/hub-modules/design.md @@ -0,0 +1,183 @@ +## Context + +`main` 46a6fc2, схема v6, агент v14, хаб v6. Пакет `src/jira_hub/` плоский (≈4,2 тыс. строк Python), веб — `web/src/`. +Что уже общее, а что завязано на Jira (разбор 23.10.2026): + +- **Уже общее, почти без правок:** `paths`, `backup`, `files`, `relay`, `release`, `client`; в вебе — `shell/` (оболочка, + боковая панель, тема), `ui/`, `styles/`, `auth/`, `projects/`, `api/`. +- **Модуль jira:** `mirror`, `history`, `attachments`, `commands`, `importer`; в вебе — `board/`, `issues/`, `feed/`, + `shell/refresh.ts` с `RefreshProvider` и `refreshStatus` (кнопка «Обновить из Jira»). +- **Переплетено:** + - `queue` (537 строк): механика пакетов общая, но `_check_setup` (настройка `bug`, файл вложения), `_check_project`, + `_assign_boards` (`ensure_sprint.board_id`), `request_fetch`, `deliver_params` (`boards`, `history`, `size`), + `READ_TYPES`, `sync_run` в `claim`/`_redeliver`, проверка `fetch_attachment` в `report_result`, + `maybe_auto_refresh` — про Jira. + - `api` (700 строк): `Hub`, сервер, авторизация, раздача веба и `tick` — общие; 35 маршрутов в одной таблице `ROUTES`, + большая часть — Jira. + - `db`: одна строка `SCHEMA` со всеми таблицами, одна цепочка `MIGRATIONS` v1→v6 (шаг v3→v4 лениво зовёт `projects`). + - `settings`: `DEFAULTS` и `validate` смешивают ключи хаба (`port`, `relay_dir`, `backup_keep`, `min_agent_version`) и + Jira (`project_key`, `issue_types`, `field_ids`, `pacing`, `link_types`, `closed_statuses` …). + - `projects`: метка и название — общие; `board_id`, `board_type`, `boards`, `base_jql` — Jira. + - `mcp_server` (680 строк): `FastMCP("jira-hub")`, привязка к проекту (`scoped`, `guard`), инструменты `projects` и + `project_create` — общие; остальное — Jira. + - `cli`: `serve`, `tokens`, `mcp`, `init` — общие; `import` и `agent publish` — Jira. + +## Goals / Non-Goals + +**Goals:** код делится на ядро и модуль jira с явным контрактом подключения; ядро не импортирует модуль jira (кроме +списка подключённых модулей и истории миграций); MCP и веб собираются по модулям; поведение снаружи не меняется. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Раскладка пакета: `jira_hub/core/` и `jira_hub/modules/jira/`.** Будущий модуль — `jira_hub/modules/wiki/`. Имя + пакета `jira_hub` и точка входа `jira-hub` остаются до переименования. В ядре новые имена не содержат «jira» + (переименовывать потом меньше). Старые пути импорта (`jira_hub.queue` и т. п.) не сохраняются — тесты переходят на + новые; внешних потребителей пакета нет. + +2. **Контракт модуля — объект `Module` в `core/modules.py`.** Поля (все необязательные, кроме `name`): + - `name` — `"jira"`; + - `schema` — SQL таблиц модуля (`CREATE TABLE IF NOT EXISTS …`); + - `commands` — типы команд модуля: спецификация параметров (нынешний `commands.SPEC`), признак «только чтение» + (нынешние `READ_TYPES`), паузы по умолчанию; + - обработчики очереди (решение 4); + - `routes` — маршруты HTTP в формате нынешней `ROUTES` (`method, regex, handler, scope`); + - `settings` — значения по умолчанию, проверка значений, ключи для агента; + - `tick` — фоновые работы (у jira — автообновление); + - `mcp_tools` — функция регистрации инструментов на сервере MCP модуля; + - `cli` — свои подкоманды (`import`, `agent publish`). + Список модулей — `core/registry.py` (`MODULES = [jira.MODULE]`). При сборке ядро проверяет, что имена типов команд, + ключи настроек и маршруты (метод + шаблон) не пересекаются между ядром и модулями; пересечение — ошибка старта, а не + молчаливое перекрытие. + +3. **База: одна схема и одна версия на весь хаб.** Ядро собирает `SCHEMA` из своих таблиц (`setting`, `project`, `batch`, + `command`, `agent_status`) и `schema` модулей. `schema_version` остаётся одной (6), цепочка `MIGRATIONS` — одной + упорядоченной историей в `core/db.py` (шаги v1…v6 не переписываются; шаг v3→v4 по-прежнему лениво зовёт функцию + модуля). Новый модуль добавит свои таблицы следующей версией (v7). *Отвергнуто:* версия схемы на модуль — порядок + миграций между модулями пришлось бы согласовывать, а копия базы перед миграцией делается на файл целиком. + Существующие таблицы не переименовываются; `sync_run` остаётся таблицей модуля jira. + +4. **Очередь: ядро — механика, модуль — смысл типов.** В `core/queue.py` остаются пакеты, порядок, одна команда в полёте, + временные ключи `new:`, довыдача до 3 раз, отчёты, отмена, повтор, резервная копия перед первым пишущим пакетом, + выборка пакетов. Модуль даёт обработчики, которые ядро зовёт по типу команды или на весь пакет: + - `check_batch(conn, cmds, project, files_dir)` — нынешние `_check_setup`, `_check_project`, `_assign_boards`; + - `deliver_params(conn, cmd, files_dir)` — дописывание параметров при выдаче; + - `on_claim(conn, cmd, now, redelivery)` — нынешняя работа с `sync_run` в `claim`; + - `on_failed(conn, cmd, now)` — закрытие `sync_run` после исчерпанных выдач; + - `on_result(conn, cmd, status, result, files_dir)` — всё, что отчёт делает по типу команды (решение 12); + - `check_confirmed(conn, cmd)` — подтверждение по состоянию (`commands.check_confirmed`); + - `tail_command(keys)` — хвостовой `refresh` пишущего пакета; + - временные ключи: какой тип их определяет (`ensure_issue.temp_key`), где они встречаются в параметрах + (`referenced_keys`, `temp_keys_used`), подстановка (`substitute`) и поведение при неразрешённых ключах (у `refresh` — + выбросить временные ключи и выполнить по остальным). Механика «зависимая команда ждёт создающую, отменённое создание + → `skipped` с текстом» остаётся в ядре. + Особые входы модуля (`request_fetch`, `maybe_auto_refresh`, создание проекта с пакетом `refresh`) переезжают в модуль + jira и пользуются публичными функциями очереди ядра (`create_batch`, `_insert_batch` → публичный `insert_batch`). + Порядок проверок и тексты ошибок сохраняются: на них смотрят тесты и MCP. + +5. **Один агент — пока.** `agent_status` (одна строка), агентский токен, `release/`, `claim` без вида агента — как сейчас, + код лежит в ядре. Виды агентов (`jira`, `eva`) появятся в change модуля wiki, вместе со вторым агентом, на котором их + можно проверить. Здесь только шов: каждый тип команды знает свой модуль (решение 2), значит, выдачу по виду агента + потом можно будет добавить фильтром в `claim`. *Отвергнуто:* ввести виды агентов сейчас — миграция схемы и смена + протокола без второго агента, на котором их можно проверить (анти-цель «миграция схемы»). + +6. **HTTP: сервер в ядре, маршруты по модулям.** `core/api.py` — `Hub`, `Handler`, авторизация двумя токенами, раздача + веба, `tick`, маршруты ядра (`/agent/claim`, `/agent/settings`, `/agent/commands/…`, `/agent/release`, + `/agent/files…`, `/files`, `/batches…`, `/commands/…`, `/agent`, `/settings`, `/projects`, `/app…`, `/`). Маршруты + задач, доски, ленты, заметок, вложений, `/agent/state`, `/agent/history`, `/agent/known-keys`, `/refresh`, + `/sync-runs` — в `modules/jira/api.py`. Адреса, коды и тела ответов те же. `POST /projects` остаётся в ядре, а пакет + «Новый проект» ставит обработчик модуля `on_project_created` (решение 8). + +7. **Настройки: ядро объединяет.** У ядра свои ключи (`port`, `relay_dir`, `backup_keep`, `min_agent_version`, + `settings_version`, `initialized`, `tokens`, `schema_version`), у модуля jira — остальные. `GET /settings`, + `PUT /settings`, `for_agent` и `init --config` работают по объединённому набору, как сейчас. `pacing` — ключ модуля + jira (паузы по типам его команд). + +8. **Проекты: метка и название — ядро, доски — модуль.** Таблица `project` не меняется (колонки `board_id`, + `board_type` остаются в ней). `core/projects.py` — создание, список, проверка метки; `boards`, `base_jql`, + `migrate_v3`, `ensure_legacy` — в `modules/jira/projects.py`. После создания проекта ядро зовёт + `on_project_created(conn, project)` модулей; jira ставит пакет «Новый проект <метка>» в той же транзакции, как сейчас. + Корень вики проекта добавит change модуля wiki. + +9. **MCP: общий слой в ядре, сервер на модуль.** `core/mcp.py` — `client`, `guard`, `scoped`, привязка к проекту, + инструменты `projects` и `project_create`, запуск `FastMCP(<имя>)`. `modules/jira/mcp.py` — остальные инструменты. + `jira-hub mcp [<модуль>] [--project <метка>]`: модуль по умолчанию — `jira`; имя сервера модуля jira — `jira-hub` + (как сейчас), набор инструментов и их тексты не меняются. Неизвестный модуль — код 2 и список модулей в stderr. + Будет ли сервер wiki нести `projects`/`project_create` — решит change модуля wiki. + +10. **Веб: модуль — папка и описание.** `web/src/modules/jira/` вбирает `board/`, `issues/`, `feed/` и обновление из + Jira (`refresh.ts`, `RefreshProvider`, `refreshStatus`). `web/src/modules/jira/index.tsx` экспортирует описание + модуля: страницы (`PAGE_ROUTES` без изменения адресов `/board`, `/issues`, `/feed`, `/` → `/board`), пункты боковой + панели, действие панели (кнопка обновления с её событием), провайдер для `App.tsx`, окно карточки + (`/issues/{key}`). `web/src/modules/index.ts` — список модулей; `shell/pages.tsx`, `Sidebar.tsx`, `Layout.tsx`, + `App.tsx` берут всё оттуда. Адреса, ключи `localStorage` (`jira-hub-sidebar`, ключи доски), golden-эталоны доски и + e2e не меняются; эталоны переезжают вместе с `board/`. + +11. **jira-agent не трогается.** `run.py` и общие части `agent.py` (транспорт папки, журнал, цикл, самообновление) + eva-agent возьмёт копией — это решение change модуля wiki, здесь только фиксируется, что протокол агента не меняется. + +12. **Граница ядра — компромисс (решение владельца 23.10.2026).** Ядро не импортирует модуль jira и не содержит понятий + Jira в логике, **кроме проектов**. Строго в модуль jira уходит: + - типы команд, вписанные в код ядра: `'refresh','fetch_attachment'` в SQL `claim` (признак «пишущий пакет» — от + `commands` модуля), `ensure_attachment` в `files.sweep` (обработчик модуля «файлы, на которые ссылаются команды»); + - паузы: `pacing` — ключ модуля, ядро берёт паузу команды и оценку пакета через модуль (`claim`, `estimate`); + - отчёты: вместо одного `check_result` — `on_result(conn, cmd, status, result, files_dir)` модуля, куда переезжает + всё, что `report_result` делает по типу команды (`attachment_file`, закрытие `sync_run`, `record_board_types`, + переименование заметки, отметка соседей, `reconcile`); + - исключения модуля → коды HTTP: модуль объявляет свои исключения с кодом и `error` (`TooLarge` → 413, + `AgentTooOld` → 409), ядро переводит их в ответ, не зная о них; + - `/settings`: модуль дописывает вычисляемые поля ответа (`base_jql`); + - `jira_utc` и вспомогательное для зеркала — в модуль; шаги миграций v1…v6 остаются в истории `core/db.py` и зовут + функции модуля лениво, как шаг v3→v4 сейчас; + - `downloads/` и `sweep_downloads` — фоновая работа модуля (`tick`), `Hub` про неё не знает; + - веб: `shell/DataStatus.tsx` и обновление из Jira — в `web/src/modules/jira/`, оболочка рисует то, что даёт модуль; + `refresh.track(batchId)` после создания проекта — обработчик модуля «проект создан» в вебе. + + **Долг — остаётся в ядре до change модуля wiki** (убрать там, «не забыть» — владелец): у проекта `board_id`, + `board_type`, проверка `board_id` в `projects.create`/`validate`, `issues_count` из таблицы `issue` в + `list_projects` и `GET /projects`; параметр доски и колонки «доска | тип доски | задач» в MCP `projects`/ + `project_create` (`fmt_projects`); поле доски в `web/src/projects/NewProjectDialog.tsx`. В change модуля wiki проекты + получат поля от модулей (корень вики, число страниц) — тогда же доски и счётчик задач переедут в модуль jira. + Каждое место долга помечается в коде комментарием `# долг hub-modules: проекты → модуль jira (change wiki)` (в TS — + `//`), чтобы найти их одним поиском. + +13. **Порядок работ — шагами, каждый зелёный.** Сначала Python (ядро, затем модуль jira по слоям: настройки и проекты → + очередь → API → MCP → CLI), потом веб, потом документация. После каждого шага — весь `uv run pytest` и `make check` + зелёные; тесты меняют только импорты и места, где подставляли зависимости, проверки (утверждения) не меняются. + Добавляются тесты на сборку модулей: пересечение типов команд, ключей настроек или маршрутов — ошибка; + `jira-hub mcp` и `jira-hub mcp jira` регистрируют один и тот же набор инструментов. + +## Risks / Trade-offs + +- **Объём переноса и конфликты.** Почти каждый файл меняет путь. Пока идёт change, других веток в хабе не открываем. +- **Скрытые зависимости от путей импорта** (ленивые импорты в `db`, подстановки в тестах через `monkeypatch` по имени + модуля). Ловятся набором тестов; поиск по строкам `"jira_hub.` — часть каждого шага. +- **Пути от расположения файла** (проверка сборщика): `WEB_APP = Path(__file__).parent / "web" / "app"` в `api.py` + (сборка Vite кладётся в `src/jira_hub/web/app/` — место сборки не меняется, меняется вычисление пути), `HUB_ROOT = + parents[2]` в `mcp_server.py` (от него — фрагмент `.mcp.json` в ответе `project_create`); `web/scripts/seed-hub.py` + импортирует `jira_hub.db/mirror/settings`; `web/scripts/test-hub.sh`, `golden-from-links.ts`, `e2e/board.spec.ts` + ссылаются на `src/board/__fixtures__` и `src/board/constants.ts`. +- **CLI `mcp <модуль>`**: `argparse` с `choices` даёт свой текст ошибки — модуль проверяется вручную, чтобы текст был + как в спеке. +- **Хаб на Mac mini** после merge — обычная выкатка: `git pull`, `make web`, `kickstart` (за владельцем). Каталог + данных, база и `.mcp.json` не меняются. + +## Migration Plan + +Миграции данных нет. Выкатка — как любая версия хаба. Откат — предыдущий коммит `main`. + +## Decisions made without discussion + +- 23.10.2026 — **виды агентов не в этом change** (решение 5). Обоснование: нет второго агента для проверки, миграция + схемы противоречит «без изменения поведения». Цена ошибки: change модуля wiki начнётся с правки ядра очереди и + `agent_status` — +полдня к нему. +- 23.10.2026 — **одна версия схемы на весь хаб** (решение 3). Цена ошибки: если модули понадобится ставить и снимать + независимо — переход на версии по модулю, миграция `setting`. +- 23.10.2026 — **раскладка `core/` + `modules/<имя>/`, без шимов старых импортов** (решения 1, 13). Цена ошибки: при + переименовании пакета — ещё один массовый перенос (он будет в любом случае). +- 23.10.2026 — **`sync_run` и `pacing` — модуля jira** (решения 3, 7). Цена ошибки: если вики понадобится свой журнал + загрузок и паузы — перенос в ядро в change модуля wiki. +- 23.10.2026 — **метка в коде на каждом месте долга проектов** (решение 12). Цена ошибки: нулевая. +- 23.10.2026 — **`jira-hub mcp` без модуля = `jira`** (решение 9), чтобы не трогать `.mcp.json` awp-ca и user scope. + Цена ошибки: нулевая до переименования. diff --git a/openspec/changes/hub-modules/proposal.md b/openspec/changes/hub-modules/proposal.md new file mode 100644 index 0000000..071de21 --- /dev/null +++ b/openspec/changes/hub-modules/proposal.md @@ -0,0 +1,48 @@ +## Why + +Хаб перестаёт быть только мостом Jira. Следом за ним идёт модуль вики (evaTeam Wiki: дерево страниц проекта +Markdown-файлами, MCP для сверки решений с тем, что зафиксировали аналитики) и спринты/бэклог в модуле Jira. Сейчас весь +код — один плоский пакет, где «команда», «агент», «проект» и «зеркало Jira» переплетены: очередь знает про `refresh`, +доски и вложения, `api` — одна таблица из 35 маршрутов, MCP — один сервер на всё. + +Решения владельца 23.10.2026 (brainstorming, jira-hub#87): +- хаб становится **sdlc-hub**: ядро + модули, Jira — первый модуль, вики — второй; +- у вики будет **отдельный агент** (не jira-agent) и **отдельный MCP** — «чтобы не перегружать»; +- **сначала перестраиваем код** под старым именем; переименование (репозиторий `aep/jira-hub`, launchd + `ru.aepif.jira-hub`, `~/.local/share/jira-hub`, имя MCP `jira-hub`, CLI) — позже, отдельной задачей, но помним о нём. + +## What Changes + +- **Ядро и модуль jira в коде.** Пакет делится на `jira_hub/core/` (база и миграции, настройки, проекты, хранилище + файлов, реле, релиз агента, очередь пакетов, HTTP-сервер и авторизация, общий слой MCP, CLI) и + `jira_hub/modules/jira/` (зеркало, журнал изменений, вложения, контракт команд, свои маршруты, инструменты MCP, + импорт). Модуль подключается к ядру описанием `Module`: таблицы, типы команд и обработчики очереди, маршруты, настройки, + фоновые работы, инструменты MCP. +- **Очередь без знания о Jira.** Механика пакетов, выдачи, довыдачи, отчётов и подтверждения остаётся в ядре; всё, что + сейчас завязано на конкретные типы (`refresh`, `ensure_attachment`, `fetch_attachment`, `ensure_sprint`), уходит в + обработчики модуля jira. +- **MCP по модулю.** `jira-hub mcp jira [--project <метка>]` — сервер модуля jira; `jira-hub mcp [--project <метка>]` без + модуля — то же самое, как сейчас (записи в `.mcp.json` awp-ca и user scope не меняются). +- **Веб по модулю.** Страницы «Доска», «Задачи», «Лента», карточка и обновление из Jira переезжают в + `web/src/modules/jira/`; оболочка собирает страницы, пункты боковой панели и действия из списка модулей. +- **Документация.** `ARCHITECTURE.md` и `CLAUDE.md` описывают ядро и модули. + +Внешнее поведение не меняется: те же адреса HTTP API и веба, те же инструменты MCP, та же схема базы (v6), тот же +протокол агента. jira-agent не трогается. + +## Non-Goals + +- Переименование в sdlc-hub (репозиторий, пакет `jira_hub`, launchd, каталог данных, имя MCP, CLI `jira-hub`). +- Модуль wiki, eva-agent, вкладка вики в вебе. +- Несколько видов агентов (токен, `agent_status`, релиз и `claim` по виду агента) — в change модуля wiki, когда появится + второй агент. +- Спринты и бэклог. +- Новые возможности API, MCP или веба. + +## Anti-Goals + +- Живой агент v14 перестал получать команды, обновляться или отчитываться. +- Запись `jira-hub` в `.mcp.json` awp-ca или в user scope перестала работать или потеряла инструменты. +- Сменились адреса веба (`/app/board`, `/app/issues`, `/app/feed`, `/app/issues/{key}`) или ключи `localStorage`. +- Миграция схемы или перенос файлов в каталоге данных. +- «Большой взрыв»: одна правка на весь пакет, которую нельзя проверить по частям; набор тестов красный между шагами. diff --git a/openspec/changes/hub-modules/specs/mcp/spec.md b/openspec/changes/hub-modules/specs/mcp/spec.md new file mode 100644 index 0000000..840744c --- /dev/null +++ b/openspec/changes/hub-modules/specs/mcp/spec.md @@ -0,0 +1,22 @@ +## MODIFIED Requirements + +### Requirement: Сервер и регистрация +`jira-hub mcp [<модуль>] [--project <метка>]` SHALL запускать MCP-сервер модуля хаба по stdio (пакет `mcp`), ходящий в +HTTP API хаба клиентским токеном из настроек. Модуль по умолчанию — `jira`: `jira-hub mcp [--project <метка>]` и +`jira-hub mcp jira [--project <метка>]` SHALL поднимать один и тот же сервер с именем `jira-hub` и одним и тем же набором +инструментов. Неизвестный модуль — код выхода 2 и список модулей в stderr, сервер не запускается. Общая регистрация — в +user scope Claude Code как `jira-hub` (`uv run --project <путь> jira-hub mcp`), без проекта. Репозиторий проекта SHALL +регистрировать `jira-hub` в своём `.mcp.json` (project scope) с `--project <метка>`; эта запись перекрывает общую. Если хаб +недоступен, каждый инструмент SHALL вернуть текст «хаб не запущен: jira-hub serve», а не исключение. + +#### Scenario: Хаб выключен +- **WHEN** вызван `issues` при остановленном хабе +- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать + +#### Scenario: Модуль по умолчанию +- **WHEN** запущены `jira-hub mcp --project AWPCA` и `jira-hub mcp jira --project AWPCA` +- **THEN** оба сервера называются `jira-hub`, список инструментов и их описания совпадают, сессия привязана к `AWPCA` + +#### Scenario: Неизвестный модуль +- **WHEN** запущен `jira-hub mcp nope` +- **THEN** код выхода 2, в stderr — «неизвестный модуль nope» и список модулей (`jira`) diff --git a/openspec/changes/hub-modules/specs/modules/spec.md b/openspec/changes/hub-modules/specs/modules/spec.md new file mode 100644 index 0000000..f339e8d --- /dev/null +++ b/openspec/changes/hub-modules/specs/modules/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Ядро и модули хаба +Хаб SHALL состоять из ядра и подключаемых модулей. Ядро SHALL владеть базой и миграциями, настройками хаба, проектами +(метка, название), хранилищем файлов, реле общей папки, релизом агента, очередью пакетов команд, HTTP-сервером с +авторизацией, раздачей веб-приложения и общим слоем MCP. Модуль SHALL подключаться к ядру описанием: таблицы, типы команд +и их обработчики в очереди, маршруты HTTP, ключи настроек, фоновые работы, инструменты MCP, подкоманды CLI. Первый модуль — +`jira` (зеркало задач, журнал изменений, вложения, команды Jira). Ядро MUST NOT импортировать модули, кроме списка +подключённых модулей и истории миграций схемы. + +При старте хаб SHALL проверять, что типы команд, ключи настроек и маршруты (метод и шаблон адреса) ядра и модулей не +пересекаются; пересечение — ошибка старта с указанием конфликта. + +Схема базы SHALL оставаться одной, с одной версией `setting.schema_version` на весь хаб; таблицы модуля входят в неё. + +#### Scenario: Конфликт типов команд +- **WHEN** два подключённых модуля объявляют тип команды `refresh` +- **THEN** хаб не стартует, ошибка называет тип и оба модуля + +#### Scenario: Поведение без изменений +- **WHEN** хаб после перестройки стартует на базе v6 с агентом v14 +- **THEN** схема остаётся v6, копия базы не создаётся; адреса API, веба, ответы и инструменты MCP — прежние; агент берёт + команды и отчитывается как раньше -- 2.47.2 From 05352ff5e53a2d7a8c36ff29f7cb891914de6dc7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:38:04 +0400 Subject: [PATCH 02/23] =?UTF-8?q?docs(plan):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?hub-modules=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-23-hub-modules.md | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-23-hub-modules.md diff --git a/docs/superpowers/plans/2026-10-23-hub-modules.md b/docs/superpowers/plans/2026-10-23-hub-modules.md new file mode 100644 index 0000000..1a588f3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-hub-modules.md @@ -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 `. + +## 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/.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)"` -- 2.47.2 From 085e96e7e877b04504621e1c2cb3cc29dcd71f9b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 18:38:13 +0400 Subject: [PATCH 03/23] =?UTF-8?q?docs(plan):=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=20DEFAULTS?= =?UTF-8?q?=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/superpowers/plans/2026-10-23-hub-modules.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-23-hub-modules.md b/docs/superpowers/plans/2026-10-23-hub-modules.md index 1a588f3..e7844c7 100644 --- a/docs/superpowers/plans/2026-10-23-hub-modules.md +++ b/docs/superpowers/plans/2026-10-23-hub-modules.md @@ -246,9 +246,9 @@ git commit -m "test: эталоны внешнего поведения пере - 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`, эталон тот же). + `ensure_defaults`, `for_agent` — по объединённому набору. `PROTECTED` — в ядре. Констант `DEFAULTS`/`AGENT_KEYS` в + ядре больше нет (их значение зависит от модулей, а импорт модулей при импорте ядра запрещён): тесты и эталон зовут + `defaults()` и `agent_keys()` — это смена обращения, утверждения и эталон те же. - 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": …}`, -- 2.47.2 From bac72c8bba04d194aa4e2912426719145cca601f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 21:35:38 +0400 Subject: [PATCH 04/23] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20R-1,=20R-2=20=D0=BF=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D1=83=20hub-modules=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/design.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md index 9833405..37528fe 100644 --- a/openspec/changes/hub-modules/design.md +++ b/openspec/changes/hub-modules/design.md @@ -181,3 +181,10 @@ - 23.10.2026 — **метка в коде на каждом месте долга проектов** (решение 12). Цена ошибки: нулевая. - 23.10.2026 — **`jira-hub mcp` без модуля = `jira`** (решение 9), чтобы не трогать `.mcp.json` awp-ca и user scope. Цена ошибки: нулевая до переименования. +- 23.10.2026 — **R-1: тест `test_core_imports` (Task 3) работает со списком временных исключений.** Обоснование: файлы + `queue`, `api`, `mcp_server`, `cli`, `files` до Task 4–6 ещё импортируют модуль jira (так задумано переносом Task 2), + без списка тест красный сразу; каждая из Task 4–6 сокращает список, к Task 6/8 он пуст. Цена ошибки: граница ядра + проверяется слабее на промежуточных шагах; закрывается Task 8. +- 23.10.2026 — **R-2: метки долга проектов ставит задача, создающая или переносящая код** (Task 3 — `core/projects.py`, + Task 6 — `core/mcp.py`, Task 7 — `NewProjectDialog.tsx`); Task 8 только проверяет поиском. Обоснование: иначе метки + теряются при переносе. Цена ошибки: нулевая. -- 2.47.2 From 808d7b710cff9ee86c57eeec787f2f1bf9780e10 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 21:39:25 +0400 Subject: [PATCH 05/23] =?UTF-8?q?test:=20=D1=8D=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=BE=D0=B9=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../fixtures/baseline/mcp_tools.expected.json | 500 ++++++++++++++++++ tests/fixtures/baseline/routes.expected.json | 206 ++++++++ tests/fixtures/baseline/schema.expected.json | 95 ++++ .../fixtures/baseline/settings.expected.json | 63 +++ tests/test_baseline.py | 44 ++ 5 files changed, 908 insertions(+) create mode 100644 tests/fixtures/baseline/mcp_tools.expected.json create mode 100644 tests/fixtures/baseline/routes.expected.json create mode 100644 tests/fixtures/baseline/schema.expected.json create mode 100644 tests/fixtures/baseline/settings.expected.json create mode 100644 tests/test_baseline.py diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json new file mode 100644 index 0000000..cc7a33b --- /dev/null +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -0,0 +1,500 @@ +[ + { + "description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.", + "inputSchema": { + "properties": { + "assignee": { + "default": "", + "title": "Assignee", + "type": "string" + }, + "epic": { + "default": "", + "title": "Epic", + "type": "string" + }, + "fact_status": { + "default": "", + "title": "Fact Status", + "type": "string" + }, + "label": { + "default": "", + "title": "Label", + "type": "string" + }, + "limit": { + "default": 50, + "title": "Limit", + "type": "integer" + }, + "neighbor": { + "default": "", + "title": "Neighbor", + "type": "string" + }, + "q": { + "default": "", + "title": "Q", + "type": "string" + }, + "sprint": { + "default": "", + "title": "Sprint", + "type": "string" + }, + "status": { + "default": "", + "title": "Status", + "type": "string" + }, + "work_kind": { + "default": "", + "title": "Work Kind", + "type": "string" + } + }, + "title": "issuesArguments", + "type": "object" + }, + "name": "issues" + }, + { + "description": "Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5\nпоследних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком\n(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.", + "inputSchema": { + "properties": { + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key" + ], + "title": "issueArguments", + "type": "object" + }, + "name": "issue" + }, + { + "description": "Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).", + "inputSchema": { + "properties": { + "comment_id": { + "title": "Comment Id", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + } + }, + "required": [ + "key", + "comment_id" + ], + "title": "commentArguments", + "type": "object" + }, + "name": "comment" + }, + { + "description": "Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из\nissue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.\nХаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,\nпо умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ\nскачиваются только в Jira.", + "inputSchema": { + "properties": { + "attachment": { + "title": "Attachment", + "type": "string" + }, + "key": { + "title": "Key", + "type": "string" + }, + "wait_seconds": { + "default": 120, + "title": "Wait Seconds", + "type": "integer" + } + }, + "required": [ + "key", + "attachment" + ], + "title": "attachmentArguments", + "type": "object" + }, + "name": "attachment" + }, + { + "description": "Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала\nсегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из\nstatus, description, dates, comment, other или all; key — одна задача (без привязки к проекту).", + "inputSchema": { + "properties": { + "hours": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Hours" + }, + "key": { + "default": "", + "title": "Key", + "type": "string" + }, + "kinds": { + "default": "", + "title": "Kinds", + "type": "string" + }, + "since": { + "default": "", + "title": "Since", + "type": "string" + }, + "until": { + "default": "", + "title": "Until", + "type": "string" + } + }, + "title": "changesArguments", + "type": "object" + }, + "name": "changes" + }, + { + "description": "Правка служебных заметок задачи (только переданные поля).", + "inputSchema": { + "properties": { + "done_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Done Text" + }, + "fact_status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Fact Status" + }, + "key": { + "title": "Key", + "type": "string" + }, + "owner_note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Owner Note" + }, + "rest_text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Rest Text" + }, + "work_kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Work Kind" + } + }, + "required": [ + "key" + ], + "title": "notes_setArguments", + "type": "object" + }, + "name": "notes_set" + }, + { + "description": "Поставить пакет команд. Каждая команда — {\"type\": <тип>, \"params\": {...}}. Хвостовой refresh хаб добавит сам.\nПараметры по типам (необязательные — в [ ]):\n- ensure_issue: temp_key (new:), issue_type (task|epic|bug), summary; [description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), labels (список), epic_key (ключ или new:), assignee (логин Jira)]\n- ensure_fields: key; [summary, description, priority, duedate (YYYY-MM-DD), planned_start (YYYY-MM-DD), planned_end (YYYY-MM-DD), epic_key (ключ или new:), assignee (логин Jira), labels_add (список), labels_remove (список)] — хотя бы одно поле\n- ensure_comment: key, marker (метка в начале комментария), body\n- ensure_status: key, target (статус или список-путь)\n- ensure_link: source, target, link_type (логическое имя из link_types); [replace (true|false)]\n- ensure_sprint: key, sprint (имя спринта или backlog)\n- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), filename (имя вложения)\n- fetch_attachment: key, attachment_id (id вложения из issue (цифры))\n- refresh: —; [keys (список), jql] — keys или jql\nПроект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.\nensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит\nфайл в хаб и подставит file, filename по умолчанию — имя файла.", + "inputSchema": { + "properties": { + "commands": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "title": "Commands", + "type": "array" + }, + "title": { + "title": "Title", + "type": "string" + } + }, + "required": [ + "title", + "commands" + ], + "title": "batch_createArguments", + "type": "object" + }, + "name": "batch_create" + }, + { + "description": "Состояние пакета и его команд.", + "inputSchema": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "batch_statusArguments", + "type": "object" + }, + "name": "batch_status" + }, + { + "description": "Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.", + "inputSchema": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + }, + "timeout_minutes": { + "default": 10, + "title": "Timeout Minutes", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "batch_waitArguments", + "type": "object" + }, + "name": "batch_wait" + }, + { + "description": "Последние пакеты со статусом.", + "inputSchema": { + "properties": { + "limit": { + "default": 10, + "title": "Limit", + "type": "integer" + } + }, + "title": "batchesArguments", + "type": "object" + }, + "name": "batches" + }, + { + "description": "Повторить команду — новый пакет с теми же параметрами.", + "inputSchema": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "command_retryArguments", + "type": "object" + }, + "name": "command_retry" + }, + { + "description": "Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —\nв ответе подсказка (POST /commands/N/cancel с force).", + "inputSchema": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "batch_cancelArguments", + "type": "object" + }, + "name": "batch_cancel" + }, + { + "description": "Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;\nwait=true — дождаться результата (до 30 мин).", + "inputSchema": { + "properties": { + "full": { + "default": false, + "title": "Full", + "type": "boolean" + }, + "keys": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Keys" + }, + "wait": { + "default": false, + "title": "Wait", + "type": "boolean" + } + }, + "title": "refreshArguments", + "type": "object" + }, + "name": "refresh" + }, + { + "description": "Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,\nсостояние самообновления и запускатель.", + "inputSchema": { + "properties": {}, + "title": "agent_statusArguments", + "type": "object" + }, + "name": "agent_status" + }, + { + "description": "Журнал обновлений зеркала.", + "inputSchema": { + "properties": { + "limit": { + "default": 10, + "title": "Limit", + "type": "integer" + } + }, + "title": "sync_logArguments", + "type": "object" + }, + "name": "sync_log" + }, + { + "description": "Настройки хаба (все или одна по ключу).", + "inputSchema": { + "properties": { + "key": { + "default": "", + "title": "Key", + "type": "string" + } + }, + "title": "settings_getArguments", + "type": "object" + }, + "name": "settings_get" + }, + { + "description": "Записать настройку: value — JSON. Поднимает settings_version.", + "inputSchema": { + "properties": { + "key": { + "title": "Key", + "type": "string" + }, + "value": { + "title": "Value", + "type": "string" + } + }, + "required": [ + "key", + "value" + ], + "title": "settings_setArguments", + "type": "object" + }, + "name": "settings_set" + }, + { + "description": "Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.", + "inputSchema": { + "properties": {}, + "title": "projectsArguments", + "type": "object" + }, + "name": "projects" + }, + { + "description": "Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент\n.mcp.json для репозитория проекта.", + "inputSchema": { + "properties": { + "board_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Board Id" + }, + "label": { + "title": "Label", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "label", + "name" + ], + "title": "project_createArguments", + "type": "object" + }, + "name": "project_create" + } +] diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json new file mode 100644 index 0000000..9f58022 --- /dev/null +++ b/tests/fixtures/baseline/routes.expected.json @@ -0,0 +1,206 @@ +[ + [ + "GET", + "^/(links)?$", + null, + "h_root" + ], + [ + "GET", + "^/agent$", + "client", + "h_agent_get" + ], + [ + "POST", + "^/agent/claim$", + "agent", + "h_claim" + ], + [ + "GET", + "^/agent/commands/(\\d+)$", + "agent", + "h_agent_command" + ], + [ + "POST", + "^/agent/commands/(\\d+)/result$", + "agent", + "h_agent_result" + ], + [ + "POST", + "^/agent/files$", + "agent", + "h_agent_files_post" + ], + [ + "GET", + "^/agent/files/([^/]+)$", + "agent", + "h_agent_file" + ], + [ + "POST", + "^/agent/history$", + "agent", + "h_agent_history" + ], + [ + "GET", + "^/agent/known-keys$", + "agent", + "h_known_keys" + ], + [ + "GET", + "^/agent/release$", + "agent", + "h_agent_release" + ], + [ + "GET", + "^/agent/settings$", + "agent", + "h_agent_settings" + ], + [ + "POST", + "^/agent/state$", + "agent", + "h_agent_state" + ], + [ + "GET", + "^/app(?:/(.*))?$", + null, + "h_app" + ], + [ + "GET", + "^/attachments/(\\d+)$", + "client", + "h_attachment_get" + ], + [ + "GET", + "^/attachments/(\\d+)/content$", + "client", + "h_attachment_content" + ], + [ + "POST", + "^/attachments/(\\d+)/fetch$", + "client", + "h_attachment_fetch" + ], + [ + "GET", + "^/batches$", + "client", + "h_batches_get" + ], + [ + "POST", + "^/batches$", + "client", + "h_batches_post" + ], + [ + "GET", + "^/batches/(\\d+)$", + "client", + "h_batch_get" + ], + [ + "POST", + "^/batches/(\\d+)/cancel$", + "client", + "h_batch_cancel" + ], + [ + "GET", + "^/board$", + "client", + "h_board" + ], + [ + "GET", + "^/changes$", + "client", + "h_changes" + ], + [ + "POST", + "^/commands/(\\d+)/cancel$", + "client", + "h_command_cancel" + ], + [ + "POST", + "^/commands/(\\d+)/retry$", + "client", + "h_command_retry" + ], + [ + "POST", + "^/files$", + "client", + "h_files_post" + ], + [ + "GET", + "^/issues$", + "client", + "h_issues" + ], + [ + "GET", + "^/issues/([^/]+)$", + "client", + "h_issue" + ], + [ + "PUT", + "^/issues/([^/]+)/note$", + "client", + "h_note_put" + ], + [ + "GET", + "^/projects$", + "client", + "h_projects_get" + ], + [ + "POST", + "^/projects$", + "client", + "h_projects_post" + ], + [ + "POST", + "^/refresh$", + "client", + "h_refresh" + ], + [ + "GET", + "^/settings$", + "client", + "h_settings_get" + ], + [ + "PUT", + "^/settings$", + "client", + "h_settings_put" + ], + [ + "GET", + "^/sync-runs$", + "client", + "h_sync_runs" + ] +] diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json new file mode 100644 index 0000000..d25a3a5 --- /dev/null +++ b/tests/fixtures/baseline/schema.expected.json @@ -0,0 +1,95 @@ +{ + "objects": [ + [ + "index", + "issue_attachment_key", + "CREATE INDEX issue_attachment_key ON issue_attachment(key)" + ], + [ + "index", + "ix_command_status", + "CREATE INDEX ix_command_status ON command(status, batch_id, seq)" + ], + [ + "index", + "ix_issue_change_at", + "CREATE INDEX ix_issue_change_at ON issue_change(at)" + ], + [ + "index", + "ix_issue_change_key", + "CREATE INDEX ix_issue_change_key ON issue_change(key, at)" + ], + [ + "index", + "ix_issue_link_target", + "CREATE INDEX ix_issue_link_target ON issue_link(target_key)" + ], + [ + "table", + "agent_status", + "CREATE TABLE agent_status (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n last_seen_at TEXT,\n agent_version TEXT,\n settings_version INTEGER,\n current_command_id INTEGER,\n last_jira_call_at TEXT\n)" + ], + [ + "table", + "attachment_file", + "CREATE TABLE attachment_file (\n attachment_id TEXT PRIMARY KEY,\n file TEXT NOT NULL,\n size INTEGER NOT NULL,\n fetched_at TEXT NOT NULL\n)" + ], + [ + "table", + "batch", + "CREATE TABLE batch (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL,\n author TEXT NOT NULL,\n created_at TEXT NOT NULL,\n cancelled_at TEXT,\n estimate_seconds INTEGER NOT NULL DEFAULT 0,\n project TEXT\n)" + ], + [ + "table", + "command", + "CREATE TABLE command (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n batch_id INTEGER NOT NULL REFERENCES batch(id),\n seq INTEGER NOT NULL,\n type TEXT NOT NULL,\n params TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'queued',\n claimed_at TEXT,\n deliveries INTEGER NOT NULL DEFAULT 0,\n finished_at TEXT,\n result TEXT,\n error TEXT,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n pause_seconds INTEGER,\n depends_on INTEGER REFERENCES command(id)\n)" + ], + [ + "table", + "issue", + "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" + ], + [ + "table", + "issue_attachment", + "CREATE TABLE issue_attachment (\n attachment_id TEXT PRIMARY KEY,\n key TEXT NOT NULL,\n filename TEXT NOT NULL,\n size INTEGER,\n mime_type TEXT,\n author TEXT,\n created TEXT,\n created_at TEXT\n)" + ], + [ + "table", + "issue_change", + "CREATE TABLE issue_change (\n history_id TEXT NOT NULL,\n seq INTEGER NOT NULL,\n key TEXT NOT NULL,\n at TEXT,\n created TEXT NOT NULL,\n author TEXT,\n field TEXT NOT NULL,\n field_id TEXT,\n field_name TEXT NOT NULL,\n from_value TEXT,\n from_string TEXT,\n to_value TEXT,\n to_string TEXT,\n fetched_at TEXT NOT NULL,\n PRIMARY KEY (history_id, seq)\n)" + ], + [ + "table", + "issue_comment", + "CREATE TABLE issue_comment (\n key TEXT NOT NULL,\n comment_id TEXT NOT NULL,\n author TEXT,\n created TEXT,\n updated TEXT,\n body TEXT NOT NULL DEFAULT '',\n created_at TEXT,\n PRIMARY KEY (key, comment_id)\n)" + ], + [ + "table", + "issue_link", + "CREATE TABLE issue_link (\n source_key TEXT NOT NULL,\n target_key TEXT NOT NULL,\n link_type TEXT NOT NULL,\n PRIMARY KEY (source_key, target_key, link_type)\n)" + ], + [ + "table", + "issue_note", + "CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)" + ], + [ + "table", + "project", + "CREATE TABLE project (\n label TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n board_id INTEGER,\n board_type TEXT,\n created_at TEXT NOT NULL\n)" + ], + [ + "table", + "setting", + "CREATE TABLE setting (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n)" + ], + [ + "table", + "sync_run", + "CREATE TABLE sync_run (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n started_at TEXT NOT NULL,\n finished_at TEXT,\n kind TEXT NOT NULL,\n query TEXT,\n issues_count INTEGER NOT NULL DEFAULT 0,\n links_count INTEGER NOT NULL DEFAULT 0,\n jira_calls INTEGER NOT NULL DEFAULT 0,\n rate_limited INTEGER NOT NULL DEFAULT 0,\n command_id INTEGER,\n history_at TEXT\n)" + ] + ], + "schema_version": 6 +} diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json new file mode 100644 index 0000000..dcfe1d1 --- /dev/null +++ b/tests/fixtures/baseline/settings.expected.json @@ -0,0 +1,63 @@ +{ + "agent_keys": [ + "project_key", + "issue_types", + "epic_name_field_id", + "field_ids", + "epic_link", + "priority_map", + "priority_value_key", + "labels", + "status_labels", + "kind_labels", + "comment_marker", + "transitions_map", + "link_types", + "board_id", + "pacing", + "min_agent_version", + "settings_version" + ], + "defaults": { + "auto_refresh_minutes": 60, + "backup_keep": 30, + "closed_statuses": [ + "Готово", + "Снято" + ], + "comment_marker": "", + "epic_link": {}, + "epic_name_field_id": null, + "field_ids": {}, + "initialized": false, + "issue_types": {}, + "kind_labels": {}, + "labels": [], + "link_types": {}, + "min_agent_version": 1, + "pacing": { + "ensure_attachment": 30, + "ensure_comment": 30, + "ensure_fields": 120, + "ensure_issue": 120, + "ensure_link": 30, + "ensure_sprint": 30, + "ensure_status": 30, + "fetch_attachment": 2, + "refresh": 2 + }, + "port": 8790, + "priority_map": {}, + "priority_value_key": "name", + "project_key": "AURORA", + "relay_dir": null, + "settings_version": 1, + "status_labels": {}, + "transitions_map": {} + }, + "protected": [ + "schema_version", + "settings_version", + "tokens" + ] +} diff --git a/tests/test_baseline.py b/tests/test_baseline.py new file mode 100644 index 0000000..6df8183 --- /dev/null +++ b/tests/test_baseline.py @@ -0,0 +1,44 @@ +"""Эталоны внешнего поведения (golden): MCP-инструменты, маршруты API, настройки, схема базы. +Перестройка на модули обязана их сохранить. Запись эталонов: BASELINE_WRITE=1 uv run pytest tests/test_baseline.py.""" +import asyncio +import json +import os +from pathlib import Path + +from jira_hub import api, db, mcp_server, settings + +FIXTURES = Path(__file__).parent / "fixtures" / "baseline" + + +def _check(name, snapshot): + path = FIXTURES / f"{name}.expected.json" + text = json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if os.environ.get("BASELINE_WRITE") == "1": + FIXTURES.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + assert path.read_text(encoding="utf-8") == text + + +def test_mcp_tools(): + tools = asyncio.run(mcp_server.mcp.list_tools()) + _check("mcp_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} + for t in tools]) + + +def test_routes(): + rows = [[method, pattern, scope, fn.__name__] for method, pattern, fn, scope in api.ROUTES] + _check("routes", sorted(rows, key=lambda r: (r[1], r[0]))) + + +def test_settings(): + _check("settings", {"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS, + "protected": sorted(settings.PROTECTED)}) + + +def test_schema(tmp_path): + conn = db.connect(tmp_path / "hub.sqlite") + rows = conn.execute("SELECT type, name, sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' " + "ORDER BY type, name").fetchall() + version = db._schema_version(conn) + conn.close() + _check("schema", {"schema_version": version, "objects": [[r[0], r[1], r[2]] for r in rows]}) -- 2.47.2 From 6d7aebe01caaf12347cf142cc0f3588301342833 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 21:50:23 +0400 Subject: [PATCH 06/23] =?UTF-8?q?refactor:=20=D0=BF=D0=B0=D0=BA=D0=B5?= =?UTF-8?q?=D1=82=D1=8B=20core=20=D0=B8=20modules.jira=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B8=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/cli.py | 88 +------------------ src/jira_hub/core/__init__.py | 0 src/jira_hub/{ => core}/api.py | 7 +- src/jira_hub/{ => core}/backup.py | 0 src/jira_hub/core/cli.py | 88 +++++++++++++++++++ src/jira_hub/{ => core}/client.py | 0 src/jira_hub/{ => core}/db.py | 0 src/jira_hub/{ => core}/files.py | 0 src/jira_hub/{ => core}/mcp_server.py | 6 +- src/jira_hub/{ => core}/paths.py | 0 src/jira_hub/{ => core}/projects.py | 0 src/jira_hub/{ => core}/queue.py | 5 +- src/jira_hub/{ => core}/relay.py | 0 src/jira_hub/{ => core}/release.py | 0 src/jira_hub/{ => core}/settings.py | 0 src/jira_hub/modules/__init__.py | 0 src/jira_hub/modules/jira/__init__.py | 0 .../{ => modules/jira}/attachments.py | 2 +- src/jira_hub/{ => modules/jira}/commands.py | 0 src/jira_hub/{ => modules/jira}/history.py | 2 +- src/jira_hub/{ => modules/jira}/importer.py | 3 +- src/jira_hub/{ => modules/jira}/mirror.py | 3 +- tests/conftest.py | 2 +- tests/test_api.py | 10 +-- tests/test_attachments.py | 3 +- tests/test_baseline.py | 2 +- tests/test_cli.py | 4 +- tests/test_commands.py | 4 +- tests/test_db.py | 4 +- tests/test_e2e_hub.py | 4 +- tests/test_files.py | 2 +- tests/test_history.py | 8 +- tests/test_importer.py | 3 +- tests/test_layout.py | 23 +++++ tests/test_mcp.py | 9 +- tests/test_mirror.py | 3 +- tests/test_projects.py | 3 +- tests/test_queue.py | 11 +-- tests/test_relay.py | 4 +- tests/test_release.py | 2 +- tests/test_settings.py | 4 +- tests/test_web_app.py | 2 +- web/scripts/seed-hub.py | 3 +- 43 files changed, 175 insertions(+), 139 deletions(-) create mode 100644 src/jira_hub/core/__init__.py rename src/jira_hub/{ => core}/api.py (98%) rename src/jira_hub/{ => core}/backup.py (100%) create mode 100644 src/jira_hub/core/cli.py rename src/jira_hub/{ => core}/client.py (100%) rename src/jira_hub/{ => core}/db.py (100%) rename src/jira_hub/{ => core}/files.py (100%) rename src/jira_hub/{ => core}/mcp_server.py (99%) rename src/jira_hub/{ => core}/paths.py (100%) rename src/jira_hub/{ => core}/projects.py (100%) rename src/jira_hub/{ => core}/queue.py (99%) rename src/jira_hub/{ => core}/relay.py (100%) rename src/jira_hub/{ => core}/release.py (100%) rename src/jira_hub/{ => core}/settings.py (100%) create mode 100644 src/jira_hub/modules/__init__.py create mode 100644 src/jira_hub/modules/jira/__init__.py rename src/jira_hub/{ => modules/jira}/attachments.py (99%) rename src/jira_hub/{ => modules/jira}/commands.py (100%) rename src/jira_hub/{ => modules/jira}/history.py (99%) rename src/jira_hub/{ => modules/jira}/importer.py (98%) rename src/jira_hub/{ => modules/jira}/mirror.py (99%) create mode 100644 tests/test_layout.py diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index de3a4a1..67c10b4 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -1,87 +1 @@ -"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, -agent publish (версия агента для самообновления).""" -import argparse -import json -import sys - -from . import db, importer, paths, projects, settings - - -def main(argv=None) -> int: - ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") - sub = ap.add_subparsers(dest="cmd", required=True) - p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") - p.add_argument("--config", required=True) - p.add_argument("--force", action="store_true") - p = sub.add_parser("serve", help="запустить HTTP API") - p.add_argument("--host", default="0.0.0.0") - p.add_argument("--port", type=int) - p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") - p.add_argument("folder") - p = sub.add_parser("mcp", help="MCP-сервер по stdio") - p.add_argument("--project", help="метка проекта, к которому привязана сессия") - sub.add_parser("tokens", help="показать токены агента и клиента") - p = sub.add_parser("agent", help="версия агента для самообновления") - agent_sub = p.add_subparsers(dest="agent_cmd", required=True) - p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") - p.add_argument("path") - args = ap.parse_args(argv) - - if args.cmd == "agent": - from . import release - try: - m = release.publish(paths.release_dir(), args.path) - except release.PublishError as e: - print(e, file=sys.stderr) - return 1 - print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") - return 0 - - if args.cmd == "serve": - from . import api - hub = api.Hub(paths.db_path()) - conn = hub.conn() - port = args.port or int(settings.get(conn, "port") or 8790) - conn.close() - api.serve(hub, args.host, port) - return 0 - if args.cmd == "mcp": - from . import mcp_server - mcp_server.run(args.project) - return 0 - - conn = db.connect(paths.db_path()) - settings.ensure_defaults(conn) - try: - if args.cmd == "init": - with open(args.config, encoding="utf-8") as f: - cfg = json.load(f) - try: - keys = settings.init_from_config(conn, cfg, force=args.force) - except RuntimeError as e: - print(e, file=sys.stderr) - return 1 - print("перенесено: " + ", ".join(keys)) - if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")): - board = projects.get(conn, projects.LEGACY_LABEL)["board_id"] - print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}") - _print_tokens(conn) - return 0 - if args.cmd == "import": - print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) - return 0 - if args.cmd == "tokens": - _print_tokens(conn) - return 0 - finally: - conn.close() - return 2 - - -def _print_tokens(conn) -> None: - tok = settings.tokens(conn) - print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}") - - -if __name__ == "__main__": - sys.exit(main()) +from .core.cli import main diff --git a/src/jira_hub/core/__init__.py b/src/jira_hub/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jira_hub/api.py b/src/jira_hub/core/api.py similarity index 98% rename from src/jira_hub/api.py rename to src/jira_hub/core/api.py index 3e5644f..3118b98 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/core/api.py @@ -15,8 +15,9 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, quote, unquote, urlparse -from . import attachments, backup, db, files, history, mirror, projects, queue, relay, release, settings -from .commands import CommandError +from . import backup, db, files, projects, queue, relay, release, settings +from ..modules.jira import attachments, history, mirror +from ..modules.jira.commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir from .queue import ClaimedError @@ -24,7 +25,7 @@ from .queue import ClaimedError log = logging.getLogger(__name__) MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт -WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос +WEB_APP = Path(__file__).resolve().parent.parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) diff --git a/src/jira_hub/backup.py b/src/jira_hub/core/backup.py similarity index 100% rename from src/jira_hub/backup.py rename to src/jira_hub/core/backup.py diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py new file mode 100644 index 0000000..33a83d5 --- /dev/null +++ b/src/jira_hub/core/cli.py @@ -0,0 +1,88 @@ +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, +agent publish (версия агента для самообновления).""" +import argparse +import json +import sys + +from . import db, paths, projects, settings +from ..modules.jira import importer + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") + p.add_argument("--config", required=True) + p.add_argument("--force", action="store_true") + p = sub.add_parser("serve", help="запустить HTTP API") + p.add_argument("--host", default="0.0.0.0") + p.add_argument("--port", type=int) + p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") + p.add_argument("folder") + p = sub.add_parser("mcp", help="MCP-сервер по stdio") + p.add_argument("--project", help="метка проекта, к которому привязана сессия") + sub.add_parser("tokens", help="показать токены агента и клиента") + p = sub.add_parser("agent", help="версия агента для самообновления") + agent_sub = p.add_subparsers(dest="agent_cmd", required=True) + p = agent_sub.add_parser("publish", help="опубликовать agent.py (версия только вверх)") + p.add_argument("path") + args = ap.parse_args(argv) + + if args.cmd == "agent": + from . import release + try: + m = release.publish(paths.release_dir(), args.path) + except release.PublishError as e: + print(e, file=sys.stderr) + return 1 + print(f"опубликована v{m['version']}, sha256 {m['sha256'][:12]}…, {m['size']} байт") + return 0 + + if args.cmd == "serve": + from . import api + hub = api.Hub(paths.db_path()) + conn = hub.conn() + port = args.port or int(settings.get(conn, "port") or 8790) + conn.close() + api.serve(hub, args.host, port) + return 0 + if args.cmd == "mcp": + from . import mcp_server + mcp_server.run(args.project) + return 0 + + conn = db.connect(paths.db_path()) + settings.ensure_defaults(conn) + try: + if args.cmd == "init": + with open(args.config, encoding="utf-8") as f: + cfg = json.load(f) + try: + keys = settings.init_from_config(conn, cfg, force=args.force) + except RuntimeError as e: + print(e, file=sys.stderr) + return 1 + print("перенесено: " + ", ".join(keys)) + if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")): + board = projects.get(conn, projects.LEGACY_LABEL)["board_id"] + print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}") + _print_tokens(conn) + return 0 + if args.cmd == "import": + print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) + return 0 + if args.cmd == "tokens": + _print_tokens(conn) + return 0 + finally: + conn.close() + return 2 + + +def _print_tokens(conn) -> None: + tok = settings.tokens(conn) + print(f"агентский токен: {tok['agent']}\nклиентский токен: {tok['client']}\nбаза: {paths.db_path()}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/jira_hub/client.py b/src/jira_hub/core/client.py similarity index 100% rename from src/jira_hub/client.py rename to src/jira_hub/core/client.py diff --git a/src/jira_hub/db.py b/src/jira_hub/core/db.py similarity index 100% rename from src/jira_hub/db.py rename to src/jira_hub/core/db.py diff --git a/src/jira_hub/files.py b/src/jira_hub/core/files.py similarity index 100% rename from src/jira_hub/files.py rename to src/jira_hub/core/files.py diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/core/mcp_server.py similarity index 99% rename from src/jira_hub/mcp_server.py rename to src/jira_hub/core/mcp_server.py index de4b6aa..1a6093c 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/core/mcp_server.py @@ -15,9 +15,9 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP from . import paths -from .attachments import fmt_size +from ..modules.jira.attachments import fmt_size from .client import HubClient, HubError, HubUnavailable -from .commands import describe, is_temp +from ..modules.jira.commands import describe, is_temp from .files import MAX_SIZE OFFLINE = "хаб не запущен: jira-hub serve" @@ -25,7 +25,7 @@ POLL_SECONDS = 5 AUTHOR = "claude" BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)" -HUB_ROOT = Path(__file__).resolve().parents[2] # корень репозитория хаба, из которого загружен пакет (решение 19) +HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19) PROJECT: str | None = None # привязка сессии, задаётся run(project) mcp = FastMCP("jira-hub") diff --git a/src/jira_hub/paths.py b/src/jira_hub/core/paths.py similarity index 100% rename from src/jira_hub/paths.py rename to src/jira_hub/core/paths.py diff --git a/src/jira_hub/projects.py b/src/jira_hub/core/projects.py similarity index 100% rename from src/jira_hub/projects.py rename to src/jira_hub/core/projects.py diff --git a/src/jira_hub/queue.py b/src/jira_hub/core/queue.py similarity index 99% rename from src/jira_hub/queue.py rename to src/jira_hub/core/queue.py index b6a4c5a..f160d3d 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/core/queue.py @@ -6,8 +6,9 @@ import logging from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) -from . import commands as C -from . import attachments, db, files, history, mirror, projects, settings +from ..modules.jira import commands as C +from . import db, files, projects, settings +from ..modules.jira import attachments, history, mirror log = logging.getLogger(__name__) diff --git a/src/jira_hub/relay.py b/src/jira_hub/core/relay.py similarity index 100% rename from src/jira_hub/relay.py rename to src/jira_hub/core/relay.py diff --git a/src/jira_hub/release.py b/src/jira_hub/core/release.py similarity index 100% rename from src/jira_hub/release.py rename to src/jira_hub/core/release.py diff --git a/src/jira_hub/settings.py b/src/jira_hub/core/settings.py similarity index 100% rename from src/jira_hub/settings.py rename to src/jira_hub/core/settings.py diff --git a/src/jira_hub/modules/__init__.py b/src/jira_hub/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jira_hub/attachments.py b/src/jira_hub/modules/jira/attachments.py similarity index 99% rename from src/jira_hub/attachments.py rename to src/jira_hub/modules/jira/attachments.py index a9cd322..633e8ee 100644 --- a/src/jira_hub/attachments.py +++ b/src/jira_hub/modules/jira/attachments.py @@ -3,7 +3,7 @@ базу и хранилище файлов, очередь и HTTP не знает.""" import re -from . import files +from ...core import files # lookahead: совпадения перекрываются, поэтому одиночный «!» ранее в тексте не съедает открывающий «!» настоящей ссылки; # после открывающего «!» пробела нет (как в Jira), иначе закрывающий «!» одной картинки породил бы ложное имя diff --git a/src/jira_hub/commands.py b/src/jira_hub/modules/jira/commands.py similarity index 100% rename from src/jira_hub/commands.py rename to src/jira_hub/modules/jira/commands.py diff --git a/src/jira_hub/history.py b/src/jira_hub/modules/jira/history.py similarity index 99% rename from src/jira_hub/history.py rename to src/jira_hub/modules/jira/history.py index 0637b57..a67e5cf 100644 --- a/src/jira_hub/history.py +++ b/src/jira_hub/modules/jira/history.py @@ -2,7 +2,7 @@ refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" from datetime import timedelta -from . import db, projects, settings +from ...core import db, projects, settings SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5) diff --git a/src/jira_hub/importer.py b/src/jira_hub/modules/jira/importer.py similarity index 98% rename from src/jira_hub/importer.py rename to src/jira_hub/modules/jira/importer.py index 7e99b50..a94bc07 100644 --- a/src/jira_hub/importer.py +++ b/src/jira_hub/modules/jira/importer.py @@ -4,7 +4,8 @@ import csv from pathlib import Path -from . import db, mirror, settings +from . import mirror +from ...core import db, settings def _rows(path: Path) -> list[dict]: diff --git a/src/jira_hub/mirror.py b/src/jira_hub/modules/jira/mirror.py similarity index 99% rename from src/jira_hub/mirror.py rename to src/jira_hub/modules/jira/mirror.py index 2ebcafe..6184cfc 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/modules/jira/mirror.py @@ -2,7 +2,8 @@ Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json -from . import attachments, db, history +from . import attachments, history +from ...core import db ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] diff --git a/tests/conftest.py b/tests/conftest.py index b0065ce..d937127 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4).""" import pytest -from jira_hub import db, projects +from jira_hub.core import db, projects @pytest.fixture diff --git a/tests/test_api.py b/tests/test_api.py index 66e6497..009af3a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,9 +11,9 @@ from datetime import timedelta import pytest -from jira_hub import api, db, files, projects, queue, release, settings -from jira_hub.client import HubClient, HubError -from jira_hub.commands import CommandError +from jira_hub.core import api, db, files, projects, queue, release, settings +from jira_hub.core.client import HubClient, HubError +from jira_hub.modules.jira.commands import CommandError @pytest.fixture @@ -157,7 +157,7 @@ def test_backup_created_on_first_write(hub, tmp_path): def test_client_unavailable(): - from jira_hub.client import HubUnavailable + from jira_hub.core.client import HubUnavailable with pytest.raises(HubUnavailable): HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") @@ -1040,7 +1040,7 @@ ATT_NAME = "скрин 1.png" def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): conn = hub["hub"].conn() try: - from jira_hub import mirror + from jira_hub.modules.jira import mirror mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) finally: diff --git a/tests/test_attachments.py b/tests/test_attachments.py index e1be1ed..a66812a 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -2,7 +2,8 @@ import json import pytest -from jira_hub import attachments, db, files, mirror +from jira_hub.core import db, files +from jira_hub.modules.jira import attachments, mirror def test_fmt_size(): diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 6df8183..9313145 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -5,7 +5,7 @@ import json import os from pathlib import Path -from jira_hub import api, db, mcp_server, settings +from jira_hub.core import api, db, mcp_server, settings FIXTURES = Path(__file__).parent / "fixtures" / "baseline" diff --git a/tests/test_cli.py b/tests/test_cli.py index ec6e092..b2fe0ec 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,7 +2,7 @@ import json import os from pathlib import Path -from jira_hub import cli, paths, projects, release, settings +from jira_hub.core import cli, paths, projects, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -67,7 +67,7 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): def test_mcp_project_option(monkeypatch): - from jira_hub import mcp_server + from jira_hub.core import mcp_server seen = [] monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project)) assert cli.main(["mcp", "--project", "AWPCA"]) == 0 diff --git a/tests/test_commands.py b/tests/test_commands.py index be46d17..f9d3b57 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,7 +1,7 @@ import pytest -from jira_hub import commands, mirror -from jira_hub.commands import CommandError +from jira_hub.modules.jira import commands, mirror +from jira_hub.modules.jira.commands import CommandError NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" diff --git a/tests/test_db.py b/tests/test_db.py index 904b4cf..95001f8 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -7,7 +7,7 @@ import threading import pytest -from jira_hub import backup, db, paths, projects, queue, settings +from jira_hub.core import backup, db, paths, projects, queue, settings def test_connect_creates_schema(open_db): @@ -154,7 +154,7 @@ def test_data_dir_is_private(tmp_path): def test_existing_backups_dir_made_private_on_start(tmp_path): - from jira_hub import api + from jira_hub.core import api backups = tmp_path / "backups" backups.mkdir() backups.chmod(0o755) diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 16db23d..a473180 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -2,8 +2,8 @@ import threading import pytest -from jira_hub import api, settings -from jira_hub.client import HubClient, HubError +from jira_hub.core import api, settings +from jira_hub.core.client import HubClient, HubError @pytest.fixture diff --git a/tests/test_files.py b/tests/test_files.py index c7c978d..bbdf65a 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -7,7 +7,7 @@ import time import pytest -from jira_hub import db, files, settings +from jira_hub.core import db, files, settings ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… DAY = 86400 diff --git a/tests/test_history.py b/tests/test_history.py index 770dd32..5ab7e6b 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,6 +1,6 @@ import pytest -from jira_hub import history +from jira_hub.modules.jira import history ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", @@ -29,7 +29,7 @@ def test_record_keeps_first_version(conn): def test_record_uses_field_settings(conn): - from jira_hub import settings + from jira_hub.core import settings settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"}) history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}]) assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end" @@ -92,7 +92,7 @@ def test_feed_closed_in_two_hours(conn): # сце def test_feed_comment(conn): # «Комментарий в ленте» - from jira_hub import mirror + from jira_hub.modules.jira import mirror mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [ {"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], []) got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS) @@ -163,7 +163,7 @@ def test_parse_kinds_unknown(): def test_issue_changes(conn): # «Задача с историей» - from jira_hub import mirror + from jira_hub.modules.jira import mirror _issue(conn, "X", ["AWPCA"]) _change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b") _change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description") diff --git a/tests/test_importer.py b/tests/test_importer.py index 00edce1..949919b 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -2,7 +2,8 @@ import csv import pytest -from jira_hub import importer, mirror, settings +from jira_hub.core import settings +from jira_hub.modules.jira import importer, mirror @pytest.fixture diff --git a/tests/test_layout.py b/tests/test_layout.py new file mode 100644 index 0000000..2671470 --- /dev/null +++ b/tests/test_layout.py @@ -0,0 +1,23 @@ +"""Раскладка пакетов: чистый импорт и пути, зависящие от места файла.""" +import subprocess +import sys +from pathlib import Path + +import jira_hub + + +def test_fresh_imports(): + r = subprocess.run( + [sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"], + capture_output=True, text=True) + assert r.returncode == 0, r.stderr + + +def test_web_app_path(): + from jira_hub.core import api + assert api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app" + + +def test_hub_root(): + from jira_hub.core import mcp_server + assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 60fcb33..8dd0f61 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -9,8 +9,9 @@ from types import SimpleNamespace import pytest -from jira_hub import api, commands, files, mcp_server, settings -from jira_hub.client import HubClient +from jira_hub.core import api, files, mcp_server, settings +from jira_hub.modules.jira import commands +from jira_hub.core.client import HubClient @pytest.fixture @@ -811,7 +812,7 @@ def test_attachment_failed(downloads, monkeypatch): def test_attachment_413(downloads, monkeypatch): - from jira_hub.client import HubError + from jira_hub.core.client import HubError att = _att("5", "запись.mp4", 26214400) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) _use(monkeypatch, hub) @@ -820,7 +821,7 @@ def test_attachment_413(downloads, monkeypatch): def test_attachment_409_passes_hub_text(downloads, monkeypatch): - from jira_hub.client import HubError + from jira_hub.core.client import HubError hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) _use(monkeypatch, hub) diff --git a/tests/test_mirror.py b/tests/test_mirror.py index fa74ae0..fe33e17 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -1,6 +1,7 @@ import pytest -from jira_hub import db, mirror +from jira_hub.core import db +from jira_hub.modules.jira import mirror @pytest.fixture diff --git a/tests/test_projects.py b/tests/test_projects.py index 12ce18b..04f254c 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -1,6 +1,7 @@ import pytest -from jira_hub import mirror, projects, settings +from jira_hub.core import projects, settings +from jira_hub.modules.jira import mirror @pytest.fixture diff --git a/tests/test_queue.py b/tests/test_queue.py index 2d663dd..4249a37 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,9 +2,10 @@ import hashlib import pytest -from jira_hub import attachments, db, files, mirror, projects, queue, settings -from jira_hub.commands import CommandError -from jira_hub.queue import ClaimedError +from jira_hub.core import db, files, projects, queue, settings +from jira_hub.modules.jira import attachments, mirror +from jira_hub.modules.jira.commands import CommandError +from jira_hub.core.queue import ClaimedError LT = {"связанные": "ASUPR_Связанные"} @@ -207,7 +208,7 @@ def test_no_backup_for_refresh_batch(conn): def test_make_backup_rotates(tmp_path, open_db): - from jira_hub import backup + from jira_hub.core import backup conn = open_db(tmp_path / "hub.sqlite") for i in range(4): backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) @@ -218,7 +219,7 @@ def test_make_backup_rotates(tmp_path, open_db): def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" - from jira_hub import backup + from jira_hub.core import backup conn = open_db(tmp_path / "hub.sqlite") backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) for i in range(4): diff --git a/tests/test_relay.py b/tests/test_relay.py index 4469904..9f38241 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -8,8 +8,8 @@ from datetime import datetime, timedelta, timezone import pytest -from jira_hub import api, relay, settings -from jira_hub.client import HubClient +from jira_hub.core import api, relay, settings +from jira_hub.core.client import HubClient @pytest.fixture diff --git a/tests/test_release.py b/tests/test_release.py index 5b5345d..ab46731 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from jira_hub import release +from jira_hub.core import release def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): diff --git a/tests/test_settings.py b/tests/test_settings.py index eb6d757..0444ca1 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,8 +3,8 @@ from pathlib import Path import pytest -from jira_hub import queue, settings -from jira_hub.commands import CommandError +from jira_hub.core import queue, settings +from jira_hub.modules.jira.commands import CommandError FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" diff --git a/tests/test_web_app.py b/tests/test_web_app.py index bfa7007..c4db10b 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse import pytest -from jira_hub import api +from jira_hub.core import api def _get(base, path): diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 0d66620..2be91f5 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -5,7 +5,8 @@ import json import sys from pathlib import Path -from jira_hub import db, mirror, paths, settings +from jira_hub.core import db, paths, settings +from jira_hub.modules.jira import mirror snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) extras_path = Path(__file__).with_name("seed-extras.json") -- 2.47.2 From 01daaa86384a44fb86acc48d2de22bfcf9c80af1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:01:06 +0400 Subject: [PATCH 07/23] =?UTF-8?q?refactor:=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F?= =?UTF-8?q?,=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80,=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=D0=BC=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/jira_hub/core/api.py | 15 +-- src/jira_hub/core/cli.py | 13 +-- src/jira_hub/core/db.py | 142 +++----------------------- src/jira_hub/core/errors.py | 11 ++ src/jira_hub/core/modules.py | 51 +++++++++ src/jira_hub/core/projects.py | 105 ++----------------- src/jira_hub/core/queue.py | 18 ++-- src/jira_hub/core/registry.py | 45 ++++++++ src/jira_hub/core/settings.py | 125 ++++++----------------- src/jira_hub/modules/jira/__init__.py | 13 +++ src/jira_hub/modules/jira/commands.py | 5 +- src/jira_hub/modules/jira/history.py | 30 +++++- src/jira_hub/modules/jira/mirror.py | 4 +- src/jira_hub/modules/jira/projects.py | 100 ++++++++++++++++++ src/jira_hub/modules/jira/schema.py | 101 ++++++++++++++++++ src/jira_hub/modules/jira/settings.py | 122 ++++++++++++++++++++++ tests/conftest.py | 5 +- tests/test_baseline.py | 2 +- tests/test_db.py | 3 +- tests/test_projects.py | 37 +++---- tests/test_queue.py | 17 +-- tests/test_registry.py | 85 +++++++++++++++ tests/test_settings.py | 29 +++--- 23 files changed, 687 insertions(+), 391 deletions(-) create mode 100644 src/jira_hub/core/errors.py create mode 100644 src/jira_hub/core/modules.py create mode 100644 src/jira_hub/core/registry.py create mode 100644 src/jira_hub/modules/jira/projects.py create mode 100644 src/jira_hub/modules/jira/schema.py create mode 100644 src/jira_hub/modules/jira/settings.py create mode 100644 tests/test_registry.py diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 3118b98..a0e51a5 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -17,7 +17,8 @@ from urllib.parse import parse_qs, quote, unquote, urlparse from . import backup, db, files, projects, queue, relay, release, settings from ..modules.jira import attachments, history, mirror -from ..modules.jira.commands import CommandError +from ..modules.jira import projects as jira_projects +from .errors import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir from .queue import ClaimedError @@ -268,7 +269,7 @@ def _time_param(q, name, default): raw = q.get(name) if raw in (None, ""): return default - value = db.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2) + value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2) if value is None: raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") return value @@ -294,7 +295,7 @@ def h_changes(hub, conn, m, q, body): def h_board(hub, conn, m, q, body): - return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q)) + return 200, mirror.board(conn, jira_projects.base_jql(conn), _project_param(conn, q)) def h_app(hub, conn, m, q, body): @@ -472,7 +473,7 @@ def h_refresh(hub, conn, m, q, body): if body.get("jql") is not None and not isinstance(body["jql"], str): raise ApiError(422, "bad_refresh", "jql — строка") if body.get("full"): - jql = projects.base_jql(conn) + jql = jira_projects.base_jql(conn) if jql is None: raise ApiError(422, "bad_refresh", "нет проектов") params = {"jql": jql} @@ -501,7 +502,7 @@ def h_sync_runs(hub, conn, m, q, body): def h_settings_get(hub, conn, m, q, body): - return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)} + return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} def h_settings_put(hub, conn, m, q, body): @@ -519,7 +520,7 @@ def h_settings_put(hub, conn, m, q, body): with db.transaction(conn): for key, value in body.items(): settings.set_(conn, key, value) - return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)} + return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} def h_projects_get(hub, conn, m, q, body): @@ -538,7 +539,7 @@ def h_projects_post(hub, conn, m, q, body): with db.transaction(conn): project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id")) batch = queue.create_batch(conn, f"Новый проект {project['label']}", author, - [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) except projects.ProjectExists as e: raise ApiError(409, "project_exists", str(e)) except projects.ProjectError as e: diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index 33a83d5..9ef1c35 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -4,7 +4,7 @@ import argparse import json import sys -from . import db, paths, projects, settings +from . import db, paths, registry, settings from ..modules.jira import importer @@ -57,15 +57,16 @@ def main(argv=None) -> int: if args.cmd == "init": with open(args.config, encoding="utf-8") as f: cfg = json.load(f) + lines: list[str] = [] try: - keys = settings.init_from_config(conn, cfg, force=args.force) + for m in registry.modules(): + if m.on_init: + lines += m.on_init(conn, cfg, args.force) except RuntimeError as e: print(e, file=sys.stderr) return 1 - print("перенесено: " + ", ".join(keys)) - if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")): - board = projects.get(conn, projects.LEGACY_LABEL)["board_id"] - print(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}") + for line in lines: + print(line) _print_tokens(conn) return 0 if args.cmd == "import": diff --git a/src/jira_hub/core/db.py b/src/jira_hub/core/db.py index dcb089b..094c5ed 100644 --- a/src/jira_hub/core/db.py +++ b/src/jira_hub/core/db.py @@ -10,95 +10,11 @@ from . import backup, paths SCHEMA_VERSION = 6 -SCHEMA = """ +CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS issue ( - key TEXT PRIMARY KEY, - id TEXT, - issue_type TEXT, - summary TEXT, - description TEXT, - status TEXT, - priority TEXT, - assignee TEXT, - reporter TEXT, - duedate TEXT, - planned_start TEXT, - planned_end TEXT, - epic_key TEXT, - labels TEXT NOT NULL DEFAULT '[]', - sprint TEXT, - created TEXT, - jira_updated TEXT, - fetched_at TEXT NOT NULL, - neighbor INTEGER NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS issue_link ( - source_key TEXT NOT NULL, - target_key TEXT NOT NULL, - link_type TEXT NOT NULL, - PRIMARY KEY (source_key, target_key, link_type) -); -CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); -CREATE TABLE IF NOT EXISTS issue_comment ( - key TEXT NOT NULL, - comment_id TEXT NOT NULL, - author TEXT, - created TEXT, - updated TEXT, - body TEXT NOT NULL DEFAULT '', - created_at TEXT, - PRIMARY KEY (key, comment_id) -); -CREATE TABLE IF NOT EXISTS issue_attachment ( - attachment_id TEXT PRIMARY KEY, - key TEXT NOT NULL, - filename TEXT NOT NULL, - size INTEGER, - mime_type TEXT, - author TEXT, - created TEXT, - created_at TEXT -); -CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key); -CREATE TABLE IF NOT EXISTS attachment_file ( - attachment_id TEXT PRIMARY KEY, - file TEXT NOT NULL, - size INTEGER NOT NULL, - fetched_at TEXT NOT NULL -); -CREATE TABLE IF NOT EXISTS issue_change ( - history_id TEXT NOT NULL, - seq INTEGER NOT NULL, - key TEXT NOT NULL, - at TEXT, - created TEXT NOT NULL, - author TEXT, - field TEXT NOT NULL, - field_id TEXT, - field_name TEXT NOT NULL, - from_value TEXT, - from_string TEXT, - to_value TEXT, - to_string TEXT, - fetched_at TEXT NOT NULL, - PRIMARY KEY (history_id, seq) -); -CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at); -CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at); -CREATE TABLE IF NOT EXISTS issue_note ( - key TEXT PRIMARY KEY, - fact_status TEXT, - work_kind TEXT, - done_text TEXT, - rest_text TEXT, - owner_note TEXT, - updated_at TEXT, - updated_by TEXT -); CREATE TABLE IF NOT EXISTS batch ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, @@ -126,19 +42,6 @@ CREATE TABLE IF NOT EXISTS command ( depends_on INTEGER REFERENCES command(id) ); CREATE INDEX IF NOT EXISTS ix_command_status ON command(status, batch_id, seq); -CREATE TABLE IF NOT EXISTS sync_run ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - started_at TEXT NOT NULL, - finished_at TEXT, - kind TEXT NOT NULL, - query TEXT, - issues_count INTEGER NOT NULL DEFAULT 0, - links_count INTEGER NOT NULL DEFAULT 0, - jira_calls INTEGER NOT NULL DEFAULT 0, - rate_limited INTEGER NOT NULL DEFAULT 0, - command_id INTEGER, - history_at TEXT -); CREATE TABLE IF NOT EXISTS project ( label TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -157,33 +60,38 @@ CREATE TABLE IF NOT EXISTS agent_status ( """ def _migrate_projects(conn) -> None: - from . import projects # внутри функции: projects импортирует db + from ..modules.jira import projects # внутри шага: ядро не импортирует модули при импорте projects.migrate_v3(conn) +def _fill_comment_utc(conn) -> None: + from ..modules.jira import history + history.fill_comment_utc(conn) + + +def schema() -> str: + """Схема ядра и модулей: CREATE TABLE IF NOT EXISTS, порядок не важен.""" + from . import registry # внутри функции: реестр импортирует модули лениво + return CORE_SCHEMA + "".join(m.schema for m in registry.modules()) + + def _add_column(table: str, column: str): - """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой.""" + """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой.""" def step(conn) -> None: if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT") return step -def _fill_comment_utc(conn) -> None: - for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall(): - conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?", - (jira_utc(r["created"]), r["key"], r["comment_id"])) - - # Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) MIGRATIONS = { 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача - 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно + 2: [], # v2 → v3: issue_comment создаётся схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно 3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений _fill_comment_utc], - 5: [], # v5 → v6: issue_attachment и attachment_file создаются SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно + 5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно } @@ -218,7 +126,7 @@ def migrate(conn: sqlite3.Connection, path=None) -> None: connect дождётся её, перечитает версию и не станет ни копировать, ни мигрировать повторно (M-2 #12). Копию делает отдельное соединение: VACUUM INTO внутри транзакции SQLite запрещает, а читатель при WAL видит базу до миграции. Вызывается только вне транзакции.""" - conn.executescript(SCHEMA) + conn.executescript(schema()) version = _schema_version(conn) if version is None: conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES ('schema_version', ?)", (json.dumps(SCHEMA_VERSION),)) @@ -272,22 +180,6 @@ def transaction(conn: sqlite3.Connection): conn.execute("COMMIT") -def jira_utc(value) -> str | None: - """Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате - now(); без смещения — UTC; неразбираемое — None.""" - if not isinstance(value, str) or not value: - return None - s = value[:-1] + "+00:00" if value.endswith("Z") else value - if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM - s = s[:-2] + ":" + s[-2:] - try: - d = datetime.fromisoformat(s) - d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) - return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() - except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось» - return None - - def utcnow() -> datetime: return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/src/jira_hub/core/errors.py b/src/jira_hub/core/errors.py new file mode 100644 index 0000000..b771270 --- /dev/null +++ b/src/jira_hub/core/errors.py @@ -0,0 +1,11 @@ +"""Ошибки, общие для ядра и модулей.""" + + +class CommandError(ValueError): + """Ошибка валидации пакета команд — наружу уходит как 422.""" + + +class HttpMapped(Exception): + """Примесь: исключение модуля, которому соответствует свой код HTTP.""" + status: int = 500 + error: str = "internal" diff --git a/src/jira_hub/core/modules.py b/src/jira_hub/core/modules.py new file mode 100644 index 0000000..fe824cb --- /dev/null +++ b/src/jira_hub/core/modules.py @@ -0,0 +1,51 @@ +"""Контракт модуля хаба: что модуль отдаёт ядру. Ядро знает только эти структуры; поля, не нужные модулю, +остаются по умолчанию.""" +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from sqlite3 import Connection +from typing import Any + +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 — не своя подкоманда diff --git a/src/jira_hub/core/projects.py b/src/jira_hub/core/projects.py index 7e0d4bb..07562e3 100644 --- a/src/jira_hub/core/projects.py +++ b/src/jira_hub/core/projects.py @@ -1,18 +1,11 @@ -"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4.""" -import logging -import re +"""Проекты-метки: таблица project, проверка метки, создание и список.""" import unicodedata -from . import db, settings +from . import db -log = logging.getLogger(__name__) - -LEGACY_LABEL = "AWPCA" -LEGACY_NAME = "АРМ КА" MAX_LEN = 100 -BOARD_TYPES = ("scrum", "kanban") _FORBIDDEN = ',"\'()\\' -_FIELDS = "label, name, board_id, board_type, created_at" +_FIELDS = "label, name, board_id, board_type, created_at" # долг hub-modules: проекты → модуль jira (change wiki) class ProjectError(ValueError): @@ -47,6 +40,7 @@ def validate(label, name, board_id) -> tuple[str, str, int | None]: raise ProjectError("name", "ожидается непустая строка") if len(name.strip()) > MAX_LEN: raise ProjectError("name", f"длиннее {MAX_LEN} символов") + # долг hub-modules: проекты → модуль jira (change wiki) if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0): raise ProjectError("board_id", "ожидается целое > 0 или null") return label, name.strip(), board_id @@ -62,12 +56,14 @@ def create(conn, label: str, name: str, board_id: int | None = None) -> dict: with db.transaction(conn): if label.casefold() in {x.casefold() for x in labels(conn)}: raise ProjectExists(label) + # долг hub-modules: проекты → модуль jira (change wiki) conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)", (label, name, board_id, db.now())) return get(conn, label) def list_projects(conn) -> list[dict]: + # долг hub-modules: проекты → модуль jira (change wiki) rows = conn.execute( f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS " "(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count " @@ -77,96 +73,9 @@ def list_projects(conn) -> list[dict]: def light_list(conn) -> list[dict]: """Проекты без issues_count (label, board_id, board_type), порядок как у list_projects.""" + # долг hub-modules: проекты → модуль jira (change wiki) return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")] def labels(conn) -> list[str]: return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")] - - -def base_jql(conn) -> str | None: - """Общая JQL обновления: все метки проектов; без проектов — None.""" - found = labels(conn) - if not found: - return None - key = settings.get(conn, "project_key") or "AURORA" - return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" - - -def boards(conn) -> list[dict]: - """Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного - проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный.""" - out: dict[int, str | None] = {} - unknown: set[int] = set() - for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"): - out.setdefault(r["board_id"], r["board_type"]) - if r["board_type"] is None: - unknown.add(r["board_id"]) - return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()] - - -def record_board_types(conn, found) -> int: - """Записать типы досок {"": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить.""" - if not isinstance(found, dict): - return 0 - updated = 0 - for raw, kind in found.items(): - if kind not in BOARD_TYPES: - continue - try: - board = int(raw) - except (TypeError, ValueError): - continue - updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount - return updated - - -_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P\S+)|in\s*\((?P[^()]*)\))\s*$", re.IGNORECASE) - - -def parse_legacy_labels(jql) -> list[str] | None: - """Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None.""" - m = _LEGACY.search(jql) if isinstance(jql, str) else None - if not m: - return None - out: list[str] = [] - for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): - label = raw.strip().strip('"') - if _label_error(label): - return None - if label.casefold() not in {x.casefold() for x in out}: - out.append(label) - return out or None - - -def ensure_legacy(conn, board_id: int | None) -> bool: - """Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён. - Неверная доска из конфига — проект без доски.""" - if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}: - return False - try: - validate(LEGACY_LABEL, LEGACY_NAME, board_id) - except ProjectError: - board_id = None - create(conn, LEGACY_LABEL, LEGACY_NAME, board_id) - return True - - -def migrate_v3(conn) -> None: - """Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL.""" - old = settings.get(conn, "base_jql") - found = parse_legacy_labels(old) - if found is None: - log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old) - return - board = settings.get(conn, "board_id") - board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None - if board is not None and len(found) > 1: - log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found)) - board = None - for label in found: - create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board) - new = base_jql(conn) - conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old)) - conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' " - "AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old)) diff --git a/src/jira_hub/core/queue.py b/src/jira_hub/core/queue.py index f160d3d..09a939f 100644 --- a/src/jira_hub/core/queue.py +++ b/src/jira_hub/core/queue.py @@ -9,6 +9,8 @@ from . import backup as _backup # noqa: F401 (имя модуля занято from ..modules.jira import commands as C from . import db, files, projects, settings from ..modules.jira import attachments, history, mirror +from ..modules.jira import projects as jira_projects +from ..modules.jira import settings as jira_settings # временно: R-1, до Task 4 log = logging.getLogger(__name__) @@ -179,7 +181,7 @@ def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dic def _insert_batch(conn, title, author, cmds, project=None) -> dict: """Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через SAVEPOINT).""" - est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) + est = C.estimate([c["type"] for c in cmds], jira_settings.pacing(conn)) with db.transaction(conn): cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)", (title, author or "api", db.now(), est, project)) @@ -231,7 +233,7 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: if b is None: return None cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))] - pacing = settings.pacing(conn) + pacing = jira_settings.pacing(conn) d = dict(b) d["status"] = _status_of(cmds, b["cancelled_at"]) d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} @@ -315,7 +317,7 @@ def deliver_params(conn, cmd, files_dir) -> dict: если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала (change-history). В базе params остаются без них: повтор команды проходит проверку как новая.""" if cmd["type"] == "refresh": - out = {**cmd["params"], "boards": projects.boards(conn)} + out = {**cmd["params"], "boards": jira_projects.boards(conn)} hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5) if hist is not None: out["history"] = hist @@ -335,7 +337,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) cmd = _redeliver(conn, now) if cmd is not None: - pause = int(settings.pacing(conn).get(cmd["type"], 0)) + pause = int(jira_settings.pacing(conn).get(cmd["type"], 0)) conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?", (now, pause, cmd["id"])) if cmd["type"] == "refresh": @@ -349,7 +351,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non if cmd is None: conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") return None - pause = int(settings.pacing(conn).get(cmd["type"], 0)) + pause = int(jira_settings.pacing(conn).get(cmd["type"], 0)) first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", (cmd["batch_id"],)).fetchone()["n"] == 0 has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type NOT IN ('refresh', 'fetch_attachment') LIMIT 1", @@ -379,7 +381,7 @@ def is_full_refresh(conn, command_id) -> bool: if not command_id: return False cmd = get_command(conn, command_id) - base = projects.base_jql(conn) + base = jira_projects.base_jql(conn) return bool(base and cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") == base) @@ -433,7 +435,7 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) if isinstance(result, dict) and "boards" in result: - projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем + jira_projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result): conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id)) if status == "done" and is_full_refresh(conn, command_id): @@ -524,7 +526,7 @@ def agent_view(conn) -> dict: def maybe_auto_refresh(conn) -> dict | None: - jql = projects.base_jql(conn) + jql = jira_projects.base_jql(conn) if jql is None: return None minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py new file mode 100644 index 0000000..302f1a8 --- /dev/null +++ b/src/jira_hub/core/registry.py @@ -0,0 +1,45 @@ +"""Реестр модулей хаба. Список собирается лениво: ядро не импортирует модули при импорте.""" +from .modules import Module + +_cache: list[Module] | None = None + + +def modules() -> list[Module]: + global _cache + if _cache is None: + from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте + from . import settings + mods = [jira.MODULE] + # маршруты ядра сюда попадут в Task 5, когда api перестанет быть владельцем всех маршрутов + check(mods, [], settings.CORE_DEFAULTS) + _cache = mods + return _cache + + +def module(name: str) -> Module | None: + return next((m for m in modules() if m.name == name), None) + + +def module_of(ctype: str) -> Module | None: + """Модуль, владеющий типом команды.""" + return next((m for m in modules() if m.commands and ctype in m.commands.types), None) + + +def check(mods: list[Module], core_routes, core_settings: dict) -> None: + """Проверить, что модули не делят тип команды, ключ настроек и маршрут между собой и с ядром.""" + types: dict[str, str] = {} + keys: dict[str, str] = {key: "ядро" for key in core_settings} + routes: dict[tuple[str, str], str] = {(r[0], r[1]): "ядро" for r in core_routes} + for m in mods: + for ctype in sorted(m.commands.types) if m.commands else (): + if ctype in types: + raise RuntimeError(f"тип {ctype}: модули {types[ctype]} и {m.name}") + types[ctype] = m.name + for key in m.settings_defaults: + if key in keys: + raise RuntimeError(f"ключ настроек {key}: {keys[key]} и модуль {m.name}") + keys[key] = m.name + for r in m.routes: + if (r[0], r[1]) in routes: + raise RuntimeError(f"маршрут {r[0]} {r[1]}: {routes[(r[0], r[1])]} и модуль {m.name}") + routes[(r[0], r[1])] = m.name diff --git a/src/jira_hub/core/settings.py b/src/jira_hub/core/settings.py index 5cca068..806c3fa 100644 --- a/src/jira_hub/core/settings.py +++ b/src/jira_hub/core/settings.py @@ -1,41 +1,22 @@ """Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version, -агент перечитывает настройки, когда видит новую версию в ответе claim.""" +агент перечитывает настройки, когда видит новую версию в ответе claim. Ключи модулей приходят из реестра.""" import json import os import secrets -DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, - "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} +from . import registry -DEFAULTS = { - "project_key": "AURORA", - "pacing": DEFAULT_PACING, +CORE_DEFAULTS = { "backup_keep": 30, - "auto_refresh_minutes": 60, "min_agent_version": 1, "port": 8790, "settings_version": 1, "initialized": False, - "issue_types": {}, - "epic_name_field_id": None, - "field_ids": {}, - "epic_link": {}, - "priority_map": {}, - "priority_value_key": "name", - "labels": [], - "status_labels": {}, - "kind_labels": {}, - "comment_marker": "", - "transitions_map": {}, - "link_types": {}, "relay_dir": None, - "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } -# Что получает агент (без токенов и служебных ключей хаба) -AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", - "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", - "transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"] +# Ключи ядра в ответе агенту: идут после ключей модулей +CORE_AGENT_KEYS = ["min_agent_version", "settings_version"] PROTECTED = {"settings_version", "schema_version", "tokens"} @@ -44,32 +25,33 @@ def _is_int(v) -> bool: return isinstance(v, int) and not isinstance(v, bool) +def defaults() -> dict: + """Умолчания ядра и всех модулей.""" + out = dict(CORE_DEFAULTS) + for m in registry.modules(): + out.update(m.settings_defaults) + return out + + +def agent_keys() -> list[str]: + """Ключи настроек для агента: сначала модулей, затем ядра.""" + out: list[str] = [] + for m in registry.modules(): + out += m.agent_keys + return out + CORE_AGENT_KEYS + + def validate(key, value) -> None: """Проверить значение настройки до записи; ValueError — на 422 в API. - base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта; - pacing — объект {тип команды: целое >= 0} только с известными типами команд; - backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535; - relay_dir — null или абсолютный путь строкой; - остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; + Сначала ключи ядра: backup_keep, min_agent_version — целые >= 0; port — целое 1..65535; + relay_dir — null или абсолютный путь строкой. Затем validate_setting модулей (их ключи). + Остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None; неизвестные ключи и ключи с умолчанием None принимаются как есть.""" - if key == "base_jql": - raise ValueError("базовая JQL собирается из проектов (GET /projects)") - if key == "board_id": - raise ValueError("доска Jira — свойство проекта (POST /projects)") - if key == "pacing": - if not isinstance(value, dict): - raise ValueError("pacing — объект {тип команды: пауза в секундах}") - for ctype, pause in value.items(): - if ctype not in DEFAULT_PACING: - raise ValueError(f"pacing: неизвестный тип команды «{ctype}»") - if not _is_int(pause) or pause < 0: - raise ValueError(f"pacing.{ctype}: ожидается целое >= 0") - return if key == "port": if not _is_int(value) or not 1 <= value <= 65535: raise ValueError("port — целое 1..65535") return - if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"): + if key in ("backup_keep", "min_agent_version"): if not _is_int(value) or value < 0: raise ValueError(f"{key} — целое >= 0") return @@ -77,14 +59,17 @@ def validate(key, value) -> None: if value is not None and (not isinstance(value, str) or not os.path.isabs(value)): raise ValueError("relay_dir — абсолютный путь папки реле или null") return - default = DEFAULTS.get(key) + for m in registry.modules(): + if m.validate_setting and m.validate_setting(key, value): + return + default = defaults().get(key) if default is not None and type(value) is not type(default): names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"} raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}") def ensure_defaults(conn) -> None: - for key, value in DEFAULTS.items(): + for key, value in defaults().items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) if get(conn, "tokens") is None: conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)", @@ -121,56 +106,8 @@ def set_(conn, key, value, bump=True) -> int: def for_agent(conn) -> dict: - return {key: get(conn, key) for key in AGENT_KEYS} + return {key: get(conn, key) for key in agent_keys()} def tokens(conn) -> dict: return get(conn, "tokens") - - -def pacing(conn) -> dict: - return {**DEFAULT_PACING, **(get(conn, "pacing") or {})} - - -def pacing_from_config(p: dict) -> dict: - """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" - issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) - return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, - "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2} - - -def _issue_types(cfg: dict) -> dict: - """issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть - issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»).""" - out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")} - if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg: - out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")} - return out - - -def init_from_config(conn, cfg: dict, force=False) -> list[str]: - """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira - (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" - if get(conn, "initialized") and not force: - raise RuntimeError("хаб уже инициализирован; повторный init — с --force") - project = cfg["project_key"] - values = { - "project_key": project, - "issue_types": _issue_types(cfg), - "epic_name_field_id": cfg.get("epic_name_field_id"), - "field_ids": cfg.get("fields", {}), - "epic_link": cfg.get("epic_link", {}), - "priority_map": cfg.get("priority_map", {}), - "priority_value_key": cfg.get("priority_value_key", "name"), - "labels": cfg.get("labels", []), - "status_labels": cfg.get("status_labels", {}), - "kind_labels": cfg.get("kind_labels", {}), - "comment_marker": cfg.get("comment_marker", ""), - "transitions_map": cfg.get("transitions", {}).get("map", {}), - "link_types": cfg.get("link_types", {}), - "pacing": pacing_from_config(cfg.get("pacing", {})), - } - for key, value in values.items(): - set_(conn, key, value, bump=False) - set_(conn, "initialized", True) - return sorted(values) diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index e69de29..fe1ff7f 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -0,0 +1,13 @@ +"""Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" +from ...core.modules import Module +from . import schema, settings + +MODULE = Module( + name="jira", + schema=schema.SQL, + settings_defaults=settings.DEFAULTS, + agent_keys=tuple(settings.AGENT_KEYS), + validate_setting=settings.validate_setting, + settings_view=settings.settings_view, + on_init=settings.on_init, +) diff --git a/src/jira_hub/modules/jira/commands.py b/src/jira_hub/modules/jira/commands.py index f6d3c87..a924645 100644 --- a/src/jira_hub/modules/jira/commands.py +++ b/src/jira_hub/modules/jira/commands.py @@ -3,6 +3,7 @@ проверка «результат уже виден в зеркале» для подтверждения по состоянию.""" import re +from ...core.errors import CommandError # noqa: F401 (реэкспорт: прежнее место) from . import mirror TEMP_KEY = re.compile(r"^new:\d+$") @@ -44,10 +45,6 @@ LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"} BOOL_PARAMS = {"replace"} -class CommandError(ValueError): - """Ошибка валидации пакета — наружу уходит как 422.""" - - def is_temp(key) -> bool: return isinstance(key, str) and bool(TEMP_KEY.match(key)) diff --git a/src/jira_hub/modules/jira/history.py b/src/jira_hub/modules/jira/history.py index a67e5cf..7721b01 100644 --- a/src/jira_hub/modules/jira/history.py +++ b/src/jira_hub/modules/jira/history.py @@ -1,11 +1,35 @@ """Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" -from datetime import timedelta +from datetime import datetime, timedelta, timezone -from ...core import db, projects, settings +from . import projects +from ...core import db, settings SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5) + +def jira_utc(value) -> str | None: + """Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате + now(); без смещения — UTC; неразбираемое — None.""" + if not isinstance(value, str) or not value: + return None + s = value[:-1] + "+00:00" if value.endswith("Z") else value + if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM + s = s[:-2] + ":" + s[-2:] + try: + d = datetime.fromisoformat(s) + d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) + return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() + except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось» + return None + + +def fill_comment_utc(conn) -> None: + for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall(): + conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?", + (jira_utc(r["created"]), r["key"], r["comment_id"])) + + BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution") _REQUIRED = ("history_id", "key", "created", "field_name") _OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string") @@ -56,7 +80,7 @@ def record_changes(conn, changes: list[dict]) -> dict: cur = conn.execute( "INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, " "from_value, from_string, to_value, to_string, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - (c["history_id"], c["seq"], c["key"], db.jira_utc(c["created"]), c["created"], c.get("author"), + (c["history_id"], c["seq"], c["key"], jira_utc(c["created"]), c["created"], c.get("author"), logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"), c.get("from_string"), c.get("to"), c.get("to_string"), now)) new += cur.rowcount diff --git a/src/jira_hub/modules/jira/mirror.py b/src/jira_hub/modules/jira/mirror.py index 6184cfc..0637105 100644 --- a/src/jira_hub/modules/jira/mirror.py +++ b/src/jira_hub/modules/jira/mirror.py @@ -86,14 +86,14 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?)", [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", - db.jira_utc(c.get("created"))) for c in it["comments"]]) + history.jira_utc(c.get("created"))) for c in it["comments"]]) if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],)) conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается "INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"), - db.jira_utc(a.get("created"))) for a in it["attachments"]]) + history.jira_utc(a.get("created"))) for a in it["attachments"]]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", diff --git a/src/jira_hub/modules/jira/projects.py b/src/jira_hub/modules/jira/projects.py new file mode 100644 index 0000000..3c97813 --- /dev/null +++ b/src/jira_hub/modules/jira/projects.py @@ -0,0 +1,100 @@ +"""Проекты-метки в модуле jira: базовая JQL из меток, доски, миграция v3 → v4, проект по умолчанию.""" +import logging +import re + +from ...core import settings +from ...core.projects import ProjectError, _label_error, create, labels, validate + +log = logging.getLogger(__name__) + +LEGACY_LABEL = "AWPCA" +LEGACY_NAME = "АРМ КА" +BOARD_TYPES = ("scrum", "kanban") + + +def base_jql(conn) -> str | None: + """Общая JQL обновления: все метки проектов; без проектов — None.""" + found = labels(conn) + if not found: + return None + key = settings.get(conn, "project_key") or "AURORA" + return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" + + +def boards(conn) -> list[dict]: + """Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного + проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный.""" + out: dict[int, str | None] = {} + unknown: set[int] = set() + for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"): + out.setdefault(r["board_id"], r["board_type"]) + if r["board_type"] is None: + unknown.add(r["board_id"]) + return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()] + + +def record_board_types(conn, found) -> int: + """Записать типы досок {"": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить.""" + if not isinstance(found, dict): + return 0 + updated = 0 + for raw, kind in found.items(): + if kind not in BOARD_TYPES: + continue + try: + board = int(raw) + except (TypeError, ValueError): + continue + updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount + return updated + + +_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P\S+)|in\s*\((?P[^()]*)\))\s*$", re.IGNORECASE) + + +def parse_legacy_labels(jql) -> list[str] | None: + """Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None.""" + m = _LEGACY.search(jql) if isinstance(jql, str) else None + if not m: + return None + out: list[str] = [] + for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): + label = raw.strip().strip('"') + if _label_error(label): + return None + if label.casefold() not in {x.casefold() for x in out}: + out.append(label) + return out or None + + +def ensure_legacy(conn, board_id: int | None) -> bool: + """Завести AWPCA «АРМ КА» с доской, если проекта с меткой awpca (без учёта регистра) нет; True — заведён. + Неверная доска из конфига — проект без доски.""" + if LEGACY_LABEL.casefold() in {x.casefold() for x in labels(conn)}: + return False + try: + validate(LEGACY_LABEL, LEGACY_NAME, board_id) + except ProjectError: + board_id = None + create(conn, LEGACY_LABEL, LEGACY_NAME, board_id) + return True + + +def migrate_v3(conn) -> None: + """Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL.""" + old = settings.get(conn, "base_jql") + found = parse_legacy_labels(old) + if found is None: + log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old) + return + board = settings.get(conn, "board_id") + board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None + if board is not None and len(found) > 1: + log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found)) + board = None + for label in found: + create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board) + new = base_jql(conn) + conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old)) + conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' " + "AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old)) diff --git a/src/jira_hub/modules/jira/schema.py b/src/jira_hub/modules/jira/schema.py new file mode 100644 index 0000000..8766782 --- /dev/null +++ b/src/jira_hub/modules/jira/schema.py @@ -0,0 +1,101 @@ +"""Схема базы модуля jira: зеркало задач, журнал изменений, вложения, заметки, прогоны обновления.""" + +SQL = """ +CREATE TABLE IF NOT EXISTS issue ( + key TEXT PRIMARY KEY, + id TEXT, + issue_type TEXT, + summary TEXT, + description TEXT, + status TEXT, + priority TEXT, + assignee TEXT, + reporter TEXT, + duedate TEXT, + planned_start TEXT, + planned_end TEXT, + epic_key TEXT, + labels TEXT NOT NULL DEFAULT '[]', + sprint TEXT, + created TEXT, + jira_updated TEXT, + fetched_at TEXT NOT NULL, + neighbor INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS issue_link ( + source_key TEXT NOT NULL, + target_key TEXT NOT NULL, + link_type TEXT NOT NULL, + PRIMARY KEY (source_key, target_key, link_type) +); +CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); +CREATE TABLE IF NOT EXISTS issue_comment ( + key TEXT NOT NULL, + comment_id TEXT NOT NULL, + author TEXT, + created TEXT, + updated TEXT, + body TEXT NOT NULL DEFAULT '', + created_at TEXT, + PRIMARY KEY (key, comment_id) +); +CREATE TABLE IF NOT EXISTS issue_attachment ( + attachment_id TEXT PRIMARY KEY, + key TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER, + mime_type TEXT, + author TEXT, + created TEXT, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key); +CREATE TABLE IF NOT EXISTS attachment_file ( + attachment_id TEXT PRIMARY KEY, + file TEXT NOT NULL, + size INTEGER NOT NULL, + fetched_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS issue_change ( + history_id TEXT NOT NULL, + seq INTEGER NOT NULL, + key TEXT NOT NULL, + at TEXT, + created TEXT NOT NULL, + author TEXT, + field TEXT NOT NULL, + field_id TEXT, + field_name TEXT NOT NULL, + from_value TEXT, + from_string TEXT, + to_value TEXT, + to_string TEXT, + fetched_at TEXT NOT NULL, + PRIMARY KEY (history_id, seq) +); +CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at); +CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at); +CREATE TABLE IF NOT EXISTS issue_note ( + key TEXT PRIMARY KEY, + fact_status TEXT, + work_kind TEXT, + done_text TEXT, + rest_text TEXT, + owner_note TEXT, + updated_at TEXT, + updated_by TEXT +); +CREATE TABLE IF NOT EXISTS sync_run ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + kind TEXT NOT NULL, + query TEXT, + issues_count INTEGER NOT NULL DEFAULT 0, + links_count INTEGER NOT NULL DEFAULT 0, + jira_calls INTEGER NOT NULL DEFAULT 0, + rate_limited INTEGER NOT NULL DEFAULT 0, + command_id INTEGER, + history_at TEXT +); +""" diff --git a/src/jira_hub/modules/jira/settings.py b/src/jira_hub/modules/jira/settings.py new file mode 100644 index 0000000..bb893b6 --- /dev/null +++ b/src/jira_hub/modules/jira/settings.py @@ -0,0 +1,122 @@ +"""Настройки модуля jira: умолчания, ключи для агента, проверка значений, перенос из config.example.json.""" +from ...core import projects as core_projects +from ...core import settings as core_settings +from . import projects + +DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30, + "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "fetch_attachment": 2, "refresh": 2} + +DEFAULTS = { + "project_key": "AURORA", + "pacing": DEFAULT_PACING, + "auto_refresh_minutes": 60, + "issue_types": {}, + "epic_name_field_id": None, + "field_ids": {}, + "epic_link": {}, + "priority_map": {}, + "priority_value_key": "name", + "labels": [], + "status_labels": {}, + "kind_labels": {}, + "comment_marker": "", + "transitions_map": {}, + "link_types": {}, + "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) +} + +# Что получает агент от модуля (без токенов и служебных ключей хаба); min_agent_version и settings_version добавляет ядро +AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", + "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", + "transitions_map", "link_types", "board_id", "pacing"] + + +def _is_int(v) -> bool: + return isinstance(v, int) and not isinstance(v, bool) + + +def validate_setting(key, value) -> bool: + """True — ключ принадлежит модулю и проверен; ValueError — значение плохое (422 в API). + base_jql и board_id не пишутся: JQL собирается из проектов, доска — свойство проекта; + pacing — объект {тип команды: целое >= 0} только с известными типами команд; + auto_refresh_minutes — целое >= 0.""" + if key == "base_jql": + raise ValueError("базовая JQL собирается из проектов (GET /projects)") + if key == "board_id": + raise ValueError("доска Jira — свойство проекта (POST /projects)") + if key == "pacing": + if not isinstance(value, dict): + raise ValueError("pacing — объект {тип команды: пауза в секундах}") + for ctype, pause in value.items(): + if ctype not in DEFAULT_PACING: + raise ValueError(f"pacing: неизвестный тип команды «{ctype}»") + if not _is_int(pause) or pause < 0: + raise ValueError(f"pacing.{ctype}: ожидается целое >= 0") + return True + if key == "auto_refresh_minutes": + if not _is_int(value) or value < 0: + raise ValueError(f"{key} — целое >= 0") + return True + return False + + +def settings_view(conn) -> dict: + """Вычисляемые поля GET/PUT /settings.""" + return {"base_jql": projects.base_jql(conn)} + + +def pacing(conn) -> dict: + return {**DEFAULT_PACING, **(core_settings.get(conn, "pacing") or {})} + + +def pacing_from_config(p: dict) -> dict: + """Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд.""" + issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30) + return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment, + "ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "fetch_attachment": 2, "refresh": 2} + + +def _issue_types(cfg: dict) -> dict: + """issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть + issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»).""" + out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")} + if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg: + out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")} + return out + + +def init_from_config(conn, cfg: dict, force=False) -> list[str]: + """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira + (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" + if core_settings.get(conn, "initialized") and not force: + raise RuntimeError("хаб уже инициализирован; повторный init — с --force") + project = cfg["project_key"] + values = { + "project_key": project, + "issue_types": _issue_types(cfg), + "epic_name_field_id": cfg.get("epic_name_field_id"), + "field_ids": cfg.get("fields", {}), + "epic_link": cfg.get("epic_link", {}), + "priority_map": cfg.get("priority_map", {}), + "priority_value_key": cfg.get("priority_value_key", "name"), + "labels": cfg.get("labels", []), + "status_labels": cfg.get("status_labels", {}), + "kind_labels": cfg.get("kind_labels", {}), + "comment_marker": cfg.get("comment_marker", ""), + "transitions_map": cfg.get("transitions", {}).get("map", {}), + "link_types": cfg.get("link_types", {}), + "pacing": pacing_from_config(cfg.get("pacing", {})), + } + for key, value in values.items(): + core_settings.set_(conn, key, value, bump=False) + core_settings.set_(conn, "initialized", True) + return sorted(values) + + +def on_init(conn, cfg: dict, force: bool) -> list[str]: + """init --config: настройки Jira и проект AWPCA; строки для вывода.""" + lines = ["перенесено: " + ", ".join(init_from_config(conn, cfg, force=force))] + if projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")): + board = core_projects.get(conn, projects.LEGACY_LABEL)["board_id"] + lines.append(f"проект {projects.LEGACY_LABEL} «{projects.LEGACY_NAME}», доска {board}") + return lines diff --git a/tests/conftest.py b/tests/conftest.py index d937127..88a37f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 import pytest from jira_hub.core import db, projects +from jira_hub.modules.jira import projects as jira_projects @pytest.fixture @@ -25,9 +26,9 @@ def add_project(): """add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict: завести проект.""" def make(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum"): if name is None: - name = projects.LEGACY_NAME if label == projects.LEGACY_LABEL else label + name = jira_projects.LEGACY_NAME if label == jira_projects.LEGACY_LABEL else label projects.create(conn, label, name, board_id) if board_id is not None and board_type is not None: - projects.record_board_types(conn, {str(board_id): board_type}) + jira_projects.record_board_types(conn, {str(board_id): board_type}) return projects.get(conn, label) return make diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 9313145..7b09b31 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -31,7 +31,7 @@ def test_routes(): def test_settings(): - _check("settings", {"defaults": settings.DEFAULTS, "agent_keys": settings.AGENT_KEYS, + _check("settings", {"defaults": settings.defaults(), "agent_keys": settings.agent_keys(), "protected": sorted(settings.PROTECTED)}) diff --git a/tests/test_db.py b/tests/test_db.py index 95001f8..9a25ddd 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -8,6 +8,7 @@ import threading import pytest from jira_hub.core import backup, db, paths, projects, queue, settings +from jira_hub.modules.jira import history def test_connect_creates_schema(open_db): @@ -324,7 +325,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): ("9999-12-31T23:00:00-0300", None), ("0001-01-01T00:00:00+0300", None), # M-1: выход за диапазон datetime ]) def test_jira_utc(raw, want): - assert db.jira_utc(raw) == want + assert history.jira_utc(raw) == want def _v4_database(path): diff --git a/tests/test_projects.py b/tests/test_projects.py index 04f254c..2d2f937 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -2,6 +2,7 @@ import pytest from jira_hub.core import projects, settings from jira_hub.modules.jira import mirror +from jira_hub.modules.jira import projects as jira_projects @pytest.fixture @@ -13,12 +14,12 @@ def conn(open_db): def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске projects.create(conn, "AWPCA", "a", 4216) - projects.record_board_types(conn, {"4216": "scrum"}) + jira_projects.record_board_types(conn, {"4216": "scrum"}) projects.create(conn, "NEW", "n", 4216) assert projects.get(conn, "NEW")["board_type"] is None - assert projects.boards(conn) == [{"id": 4216, "type": None}] - projects.record_board_types(conn, {"4216": "scrum"}) - assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}] + assert jira_projects.boards(conn) == [{"id": 4216, "type": None}] + jira_projects.record_board_types(conn, {"4216": "scrum"}) + assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}] assert [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "scrum"] @@ -26,7 +27,7 @@ def test_boards_diverged_types_first_by_order(conn): projects.create(conn, "A", "a", 7); projects.create(conn, "B", "b", 7) conn.execute("UPDATE project SET board_type = 'kanban' WHERE label = 'B'") conn.execute("UPDATE project SET board_type = 'scrum' WHERE label = 'A'") - assert projects.boards(conn) == [{"id": 7, "type": "scrum"}] + assert jira_projects.boards(conn) == [{"id": 7, "type": "scrum"}] def test_light_list_same_order_no_count(conn): @@ -84,32 +85,32 @@ def test_same_label_other_case(conn): def test_base_jql(conn): - assert projects.base_jql(conn) is None + assert jira_projects.base_jql(conn) is None projects.create(conn, "AWPCA", "АРМ КА"); projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК") - assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' + assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' settings.set_(conn, "project_key", "AUR") - assert projects.base_jql(conn).startswith("project = AUR AND") + assert jira_projects.base_jql(conn).startswith("project = AUR AND") def test_base_jql_single_and_reserved_words(conn): projects.create(conn, "AWPCA", "АРМ КА") - assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA")' + assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA")' projects.create(conn, "EMPTY", "Пустой") # зарезервированное слово JQL — в кавычках - assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "EMPTY")' + assert jira_projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "EMPTY")' def test_boards_dedup_and_types(conn): projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120) projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d") - projects.record_board_types(conn, {"4216": "scrum"}) - assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + jira_projects.record_board_types(conn, {"4216": "scrum"}) + assert jira_projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской @pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}]) def test_record_board_types_ignores(conn, boards): projects.create(conn, "B", "b", 5120) - assert projects.record_board_types(conn, boards) == 0 + assert jira_projects.record_board_types(conn, boards) == 0 assert projects.get(conn, "B")["board_type"] is None @@ -125,7 +126,7 @@ def test_record_board_types_ignores(conn, boards): ("", None), (None, None), (5, None), ]) def test_parse_legacy_labels(jql, labels): - assert projects.parse_legacy_labels(jql) == labels + assert jira_projects.parse_legacy_labels(jql) == labels def test_add_project_fixture(conn, add_project): @@ -134,16 +135,16 @@ def test_add_project_fixture(conn, add_project): def test_ensure_legacy(conn): - assert projects.ensure_legacy(conn, 4216) is True + assert jira_projects.ensure_legacy(conn, 4216) is True assert projects.get(conn, "AWPCA")["board_id"] == 4216 and projects.get(conn, "AWPCA")["name"] == "АРМ КА" - assert projects.ensure_legacy(conn, 1) is False + assert jira_projects.ensure_legacy(conn, 1) is False def test_ensure_legacy_skips_case_variant_and_bad_board(open_db): c = open_db() settings.ensure_defaults(c) projects.create(c, "awpca", "x") - assert projects.ensure_legacy(c, 4216) is False + assert jira_projects.ensure_legacy(c, 4216) is False c2 = open_db() settings.ensure_defaults(c2) - assert projects.ensure_legacy(c2, "oops") is True and projects.get(c2, "AWPCA")["board_id"] is None + assert jira_projects.ensure_legacy(c2, "oops") is True and projects.get(c2, "AWPCA")["board_id"] is None diff --git a/tests/test_queue.py b/tests/test_queue.py index 4249a37..8b599c9 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -4,6 +4,7 @@ import pytest from jira_hub.core import db, files, projects, queue, settings from jira_hub.modules.jira import attachments, mirror +from jira_hub.modules.jira import projects as jira_projects from jira_hub.modules.jira.commands import CommandError from jira_hub.core.queue import ClaimedError @@ -424,7 +425,7 @@ def test_cancelled_batch_with_done_command_is_cancelled(conn): def test_empty_full_refresh_keeps_neighbors(conn, add_project): add_project(conn) - jql = projects.base_jql(conn) + jql = jira_projects.base_jql(conn) mirror.apply_state(conn, [{"key": "A-1", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") # прочитана давно — до старта прогона queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) @@ -444,7 +445,7 @@ def test_cancel_finished_batch_stays_done(conn): def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project): add_project(conn) - jql = projects.base_jql(conn) + jql = jira_projects.base_jql(conn) mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) r = queue.claim(conn) @@ -948,7 +949,7 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 def _full_run(conn, result, started_at=None, status="done"): """Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона.""" - queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) c = queue.claim(conn) if started_at: conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"])) @@ -958,7 +959,7 @@ def _full_run(conn, result, started_at=None, status="done"): def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки» add_project(conn) - b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) got = queue.claim(conn) assert got["params"]["history"] == { "jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'} @@ -969,7 +970,7 @@ def test_regular_refresh_gets_since(conn, add_project): # «Р add_project(conn) _full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00") _full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00") - queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"} @@ -977,10 +978,10 @@ def test_new_project_gets_since_and_backfill(conn, add_project): # «Н add_project(conn) _full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00") add_project(conn, "STOCKS_DRRK", board_id=5120) - queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) h = queue.claim(conn)["params"]["history"] assert h["since"] == "2026-10-22T08:45:00Z" - assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done" + assert h["jql"] == "(" + jira_projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done" assert "STOCKS_DRRK" in h["jql"] @@ -1007,7 +1008,7 @@ def test_no_projects_no_history(conn): def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3 add_project(conn) - b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) first = queue.claim(conn) again = queue.claim(conn) # отчёта нет — довыдача assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"] diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..ca51eae --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,85 @@ +"""Реестр модулей: состав, конфликты имён, границы импортов ядра.""" +import ast +from pathlib import Path + +import pytest + +from jira_hub.core import registry +from jira_hub.core.modules import CommandHooks, Module + +CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" + +# временно: R-1, сокращается в Task 4–6 +PENDING = {"api.py", "cli.py", "mcp_server.py", "queue.py"} + + +def _noop(*a, **k): + return None + + +def _hooks(types): + names = [f for f in CommandHooks.__dataclass_fields__ if f not in ("types", "read_types")] + return CommandHooks(types=frozenset(types), read_types=frozenset(), **{n: _noop for n in names}) + + +def test_jira_registered(): + assert [m.name for m in registry.modules()] == ["jira"] + assert registry.module("jira").name == "jira" + assert registry.module("nope") is None + + +def test_command_type_conflict(): + a = Module(name="jira", commands=_hooks({"refresh"})) + b = Module(name="x", commands=_hooks({"refresh"})) + with pytest.raises(RuntimeError) as e: + registry.check([a, b], [], {}) + assert "refresh" in str(e.value) and "jira" in str(e.value) and "x" in str(e.value) + + +def test_settings_conflict(): + with pytest.raises(RuntimeError, match="port"): + registry.check([Module(name="x", settings_defaults={"port": 1})], [], {"port": 8790}) + + +def test_route_conflict(): + core = [("GET", r"^/projects$", _noop, "client")] + m = Module(name="x", routes=(("GET", r"^/projects$", _noop, "client"),)) + with pytest.raises(RuntimeError, match="/projects"): + registry.check([m], core, {}) + + +def test_module_of(monkeypatch): + a = Module(name="a", commands=_hooks({"t1"})) + monkeypatch.setattr(registry, "_cache", [a]) + assert registry.module_of("t1") is a and registry.module_of("t2") is None + + +def _module_imports(tree, top_level_only): + """Импорты пакета jira_hub.modules (абсолютные и относительные из core/).""" + found = [] + for n in (tree.body if top_level_only else ast.walk(tree)): + if isinstance(n, ast.Import): + names = [a.name for a in n.names] + elif isinstance(n, ast.ImportFrom): + prefix = {0: "", 1: "jira_hub.core.", 2: "jira_hub."}[n.level] + base = (prefix + (n.module or "")).rstrip(".") + names = [base] + [base + "." + a.name for a in n.names] + else: + continue + if any(x == "jira_hub.modules" or x.startswith("jira_hub.modules.") for x in names): + found.append(n) + return found + + +def test_core_imports(): + dirty = set() + for path in sorted(CORE.glob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8")) + if path.name in ("registry.py", "db.py"): + # только внутри функций: на верхнем уровне модуля импортов модулей нет + assert not _module_imports(tree, top_level_only=True), path.name + continue + if _module_imports(tree, top_level_only=False): + dirty.add(path.name) + assert dirty - PENDING == set(), f"ядро импортирует модули: {sorted(dirty - PENDING)}" + assert PENDING - dirty == set(), f"PENDING устарел, уберите: {sorted(PENDING - dirty)}" diff --git a/tests/test_settings.py b/tests/test_settings.py index 0444ca1..e243ec3 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest from jira_hub.core import queue, settings +from jira_hub.modules.jira import settings as jira_settings from jira_hub.modules.jira.commands import CommandError FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -25,9 +26,9 @@ def test_defaults_and_tokens(conn): def test_defaults_without_jql_and_board(conn): - assert "base_jql" not in settings.DEFAULTS and "board_id" not in settings.DEFAULTS + assert "base_jql" not in settings.defaults() and "board_id" not in settings.defaults() assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn) - assert "base_jql" not in settings.AGENT_KEYS and "board_id" in settings.AGENT_KEYS + assert "base_jql" not in settings.agent_keys() and "board_id" in settings.agent_keys() assert settings.for_agent(conn)["board_id"] is None @@ -53,18 +54,18 @@ def test_protected_keys(conn, key): def test_init_from_config(conn): cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) - keys = settings.init_from_config(conn, cfg) + keys = jira_settings.init_from_config(conn, cfg) assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn) assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} - assert settings.get(conn, "pacing") == settings.DEFAULT_PACING + assert settings.get(conn, "pacing") == jira_settings.DEFAULT_PACING agent_view = settings.for_agent(conn) assert "tokens" not in agent_view and "auth" not in agent_view and "base_url" not in agent_view assert agent_view["link_types"]["связанные"] == "ASUPR_Связанные" with pytest.raises(RuntimeError): - settings.init_from_config(conn, cfg) - settings.init_from_config(conn, cfg, force=True) + jira_settings.init_from_config(conn, cfg) + jira_settings.init_from_config(conn, cfg, force=True) def test_relay_dir_default_and_validation(tmp_path, open_db): @@ -76,13 +77,13 @@ def test_relay_dir_default_and_validation(tmp_path, open_db): for bad in ("rshb/jira-bridge", "", 5, ["/x"]): with pytest.raises(ValueError): settings.validate("relay_dir", bad) - assert "relay_dir" not in settings.AGENT_KEYS + assert "relay_dir" not in settings.agent_keys() def test_init_without_bug_keys_rejects_bug(conn): """Спека data-model «Конфиг без ключей ошибки»: bug в issue_types нет, постановка bug — 422.""" cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) - settings.init_from_config(conn, cfg) + jira_settings.init_from_config(conn, cfg) assert set(settings.get(conn, "issue_types")) == {"task", "epic"} with pytest.raises(CommandError, match="нет настройки issue_types.bug"): queue.create_batch(conn, "ошибка", "claude", [{"type": "ensure_issue", "params": { @@ -96,22 +97,22 @@ def test_init_without_bug_keys_rejects_bug(conn): ]) def test_init_bug_from_config(conn, extra, expected): cfg = {**json.loads(FIXTURE.read_text(encoding="utf-8")), **extra} - settings.init_from_config(conn, cfg) + jira_settings.init_from_config(conn, cfg) types = settings.get(conn, "issue_types") assert types["bug"] == expected assert types["task"] == {"name": "Задача", "id": "10100"} and types["epic"] == {"name": "Фича", "id": "12300"} def test_attachment_pacing(conn): - assert settings.DEFAULT_PACING["ensure_attachment"] == 30 - assert settings.pacing(conn)["ensure_attachment"] == 30 - assert settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 + assert jira_settings.DEFAULT_PACING["ensure_attachment"] == 30 + assert jira_settings.pacing(conn)["ensure_attachment"] == 30 + assert jira_settings.pacing_from_config({"issue_seconds": 90, "comment_seconds": 20})["ensure_attachment"] == 20 settings.validate("pacing", {"ensure_attachment": 10}) def test_closed_statuses_default(conn): assert settings.get(conn, "closed_statuses") == ["Готово", "Снято"] - assert "closed_statuses" not in settings.AGENT_KEYS + assert "closed_statuses" not in settings.agent_keys() assert "closed_statuses" not in settings.for_agent(conn) @@ -123,5 +124,5 @@ def test_closed_statuses_must_be_list(): def test_pacing_fallback_for_new_type(conn): settings.set_(conn, "pacing", {"ensure_fields": 5}) - assert settings.pacing(conn)["fetch_attachment"] == 2 + assert jira_settings.pacing(conn)["fetch_attachment"] == 2 settings.validate("pacing", {"fetch_attachment": 3}) -- 2.47.2 From a4e57fac202a8913674ab85b4378f88901ac41dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:07:42 +0400 Subject: [PATCH 08/23] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20R-3,=20R-4=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md index 37528fe..56e20ae 100644 --- a/openspec/changes/hub-modules/design.md +++ b/openspec/changes/hub-modules/design.md @@ -188,3 +188,8 @@ - 23.10.2026 — **R-2: метки долга проектов ставит задача, создающая или переносящая код** (Task 3 — `core/projects.py`, Task 6 — `core/mcp.py`, Task 7 — `NewProjectDialog.tsx`); Task 8 только проверяет поиском. Обоснование: иначе метки теряются при переносе. Цена ошибки: нулевая. +- 23.10.2026 — **R-3: `jira_utc` и `fill_comment_utc` лежат в `modules/jira/history.py`, порядок ключей агента — ключи + модуля, затем ядра.** Обоснование: в `mirror.py` они дали бы цикл (mirror импортирует history); порядок ключей + сохраняет ответ `/agent/settings`. Цена ошибки: нулевая. +- 23.10.2026 — **R-4: `registry.check` получает маршруты ядра только в Task 5.** Обоснование: до разделения `api.py` + отдельных маршрутов ядра нет. Цена ошибки: пересечение маршрутов проверяется со Task 5, не с Task 3. -- 2.47.2 From c9ac2d5238259acc3b1f969772918209c6809c87 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:15:42 +0400 Subject: [PATCH 09/23] =?UTF-8?q?refactor:=20=D0=BE=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=8C=20=D1=8F=D0=B4=D1=80=D0=B0=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8F=20jira=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/api.py | 18 +- src/jira_hub/core/files.py | 11 +- src/jira_hub/core/modules.py | 4 +- src/jira_hub/core/projects.py | 4 +- src/jira_hub/core/queue.py | 395 ++++++++------------------ src/jira_hub/modules/jira/__init__.py | 4 +- src/jira_hub/modules/jira/commands.py | 4 - src/jira_hub/modules/jira/fetch.py | 39 +++ src/jira_hub/modules/jira/hooks.py | 267 +++++++++++++++++ src/jira_hub/modules/jira/projects.py | 4 +- src/jira_hub/modules/jira/refresh.py | 39 +++ tests/test_commands.py | 1 - tests/test_queue.py | 65 ++--- tests/test_queue_core.py | 84 ++++++ tests/test_registry.py | 2 +- 15 files changed, 607 insertions(+), 334 deletions(-) create mode 100644 src/jira_hub/modules/jira/fetch.py create mode 100644 src/jira_hub/modules/jira/hooks.py create mode 100644 src/jira_hub/modules/jira/refresh.py create mode 100644 tests/test_queue_core.py diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index a0e51a5..55c0abd 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -16,7 +16,7 @@ from typing import NamedTuple from urllib.parse import parse_qs, quote, unquote, urlparse from . import backup, db, files, projects, queue, relay, release, settings -from ..modules.jira import attachments, history, mirror +from ..modules.jira import attachments, fetch, history, mirror, refresh # временно: R-1, до Task 5 from ..modules.jira import projects as jira_projects from .errors import CommandError from .paths import backups_dir as default_backups_dir @@ -78,7 +78,7 @@ class Hub: conn = None try: conn = self.conn() - queue.maybe_auto_refresh(conn) + refresh.maybe_auto_refresh(conn) except Exception: # noqa: BLE001 log.exception("автообновление: сбой тика") try: @@ -216,9 +216,9 @@ def h_agent_state(hub, conn, m, q, body): raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], - authoritative=queue.is_full_refresh(conn, body.get("command_id"))) + authoritative=refresh.is_full_refresh(conn, body.get("command_id"))) if body.get("command_id"): - queue.record_state(conn, body["command_id"], out["issues"], out["links"]) + refresh.record_state(conn, body["command_id"], out["issues"], out["links"]) return 200, out @@ -346,7 +346,7 @@ def h_batches_post(hub, conn, m, q, body): try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir, project=body.get("project")) - except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) @@ -411,12 +411,12 @@ def h_attachment_fetch(hub, conn, m, q, body): if not isinstance(author, str): raise ApiError(422, "bad_request", "author — строка") try: - return 200, queue.request_fetch(conn, hub.files_dir, m.group(1), author) + return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author) except KeyError: raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None - except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17) + except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17) raise ApiError(413, "too_large", str(e)) from None - except queue.AgentTooOld as e: + except fetch.AgentTooOld as e: raise ApiError(409, "agent_too_old", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) from None @@ -449,7 +449,7 @@ def h_command_retry(hub, conn, m, q, body): project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") - except queue.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) + except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) raise ApiError(413, "too_large", str(e)) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) diff --git a/src/jira_hub/core/files.py b/src/jira_hub/core/files.py index e553181..ecb29af 100644 --- a/src/jira_hub/core/files.py +++ b/src/jira_hub/core/files.py @@ -9,6 +9,7 @@ import tempfile import time from pathlib import Path +from . import registry from .paths import secure_dir MAX_SIZE = 10 * 1024 * 1024 # 10 485 760 байт после декодирования (решение 1) @@ -130,15 +131,17 @@ def sweep_downloads(root, now: float | None = None) -> list[str]: def sweep(conn, root, now: float | None = None) -> list[str]: - """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна ensure_attachment в queued или - claimed; вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят + """Удалить файлы старше KEEP_DAYS (по mtime), на которые не ссылается ни одна команда в queued или claimed (ссылки + отдают модули: file_refs); вернуть имена удалённых. Правило одно для всех обычных файлов каталога: брошенные временные .*.tmp уходят тем же путём. Каталога нет — пусто.""" root = Path(root) if not root.is_dir(): return [] cutoff = (time.time() if now is None else now) - KEEP_DAYS * 86400 - active = {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " - "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} + active = set() + for m in registry.modules(): + if m.file_refs: + active |= m.file_refs(conn) removed = [] for p in sorted(root.iterdir()): if p.name in active or not p.is_file(): diff --git a/src/jira_hub/core/modules.py b/src/jira_hub/core/modules.py index fe824cb..4d9d290 100644 --- a/src/jira_hub/core/modules.py +++ b/src/jira_hub/core/modules.py @@ -21,8 +21,8 @@ class CommandHooks: 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] + drop_unresolved: Callable[[dict, list[str]], list[str]] # (cmd, неразрешённые): поправить cmd["params"], вернуть оставшиеся + retry_params: Callable[[dict], dict] # cmd → params нового пакета 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] # исчерпаны выдачи diff --git a/src/jira_hub/core/projects.py b/src/jira_hub/core/projects.py index 07562e3..1315aac 100644 --- a/src/jira_hub/core/projects.py +++ b/src/jira_hub/core/projects.py @@ -21,7 +21,7 @@ class ProjectExists(ProjectError): super().__init__("label", f"проект с такой меткой уже есть: {label}") -def _label_error(label) -> str | None: +def label_error(label) -> str | None: if not isinstance(label, str) or not label: return "ожидается непустая строка" if len(label) > MAX_LEN: @@ -33,7 +33,7 @@ def _label_error(label) -> str | None: def validate(label, name, board_id) -> tuple[str, str, int | None]: """Проверить поля проекта; вернуть (метка, название без пробелов по краям, доска).""" - error = _label_error(label) + error = label_error(label) if error: raise ProjectError("label", error) if not isinstance(name, str) or not name.strip(): diff --git a/src/jira_hub/core/queue.py b/src/jira_hub/core/queue.py index 09a939f..0131edc 100644 --- a/src/jira_hub/core/queue.py +++ b/src/jira_hub/core/queue.py @@ -1,16 +1,15 @@ -"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом, автообновление. -Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную агенту и -оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, решение 1).""" +"""Очередь команд: пакеты, выдача агенту по одной, отчёты, отмена, повтор, сверка с зеркалом. Механика — здесь; смысл +типов команд (проверки постановки, паузы, журнал синхронизаций, отчёты) даёт модуль через CommandHooks (hub-modules, +решение 4). Новых команд хаб сам не создаёт: повтор после failed — новый пакет по запросу человека. Команду, выданную +агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim — это доставка той же выдачи (bridge-reliability, +решение 1).""" import json import logging from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) -from ..modules.jira import commands as C -from . import db, files, projects, settings -from ..modules.jira import attachments, history, mirror -from ..modules.jira import projects as jira_projects -from ..modules.jira import settings as jira_settings # временно: R-1, до Task 4 +from . import db, registry, settings +from .errors import CommandError log = logging.getLogger(__name__) @@ -18,7 +17,6 @@ TERMINAL = {"done", "failed", "skipped", "cancelled", "confirmed_by_state"} MAX_DELIVERIES = 3 # выдач без отчёта, после которых команда — failed (bridge-reliability, решение 2) ERROR_LIMIT = 1500 # символов ошибки в command.error (spec commands) ONLINE_MINUTES = 5 -READ_TYPES = {"refresh", "fetch_attachment"} # не пишут в Jira: без хвостового refresh и без резервной копии class ClaimedError(Exception): @@ -27,17 +25,6 @@ class ClaimedError(Exception): self.claimed_at = claimed_at -class TooLarge(C.CommandError): - """Вложение больше предела хаба (fetch_attachment) — API отвечает 413 too_large; для POST /batches это 422.""" - - -class AgentTooOld(ValueError): - """Агент ещё не умеет fetch_attachment — API отвечает 409 agent_too_old.""" - - -FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment - - class NotClaimedError(Exception): """Отчёт по команде, которую агенту не выдавали (status = queued) — API отвечает 409.""" @@ -58,163 +45,80 @@ def _dump(value) -> str: return json.dumps(value, ensure_ascii=False) +def _hooks(ctype): + """Обработчики модуля, владеющего типом команды; None — тип никому не известен.""" + m = registry.module_of(ctype) + return m.commands if m else None + + +def _read_types() -> set[str]: + return {t for m in registry.modules() if m.commands for t in m.commands.read_types} + + # --- пакеты ----------------------------------------------------------------- -def _check_setup(conn, cmds, files_dir) -> None: - """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 - до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). - files_dir=None — хранилища нет: любой файл «не загружен».""" - bug = (settings.get(conn, "issue_types") or {}).get("bug") - bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) - for i, c in enumerate(cmds, 1): - p = c["params"] - if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: - raise C.CommandError(f"команда {i}: нет настройки issue_types.bug") - if c["type"] == "ensure_attachment": - if not files.is_sha(p["file"]): - raise C.CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") - if files_dir is None or not files.exists(files_dir, p["file"]): - raise C.CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") - if not p["filename"].strip(): - raise C.CommandError(f"команда {i}: filename пустой") - if "/" in p["filename"] or "\\" in p["filename"]: - raise C.CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") - if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]): - # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) - raise C.CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") - if c["type"] == "fetch_attachment": - if not p["attachment_id"].isdigit(): - raise C.CommandError(f"команда {i}: attachment_id — цифры id вложения из issue") - row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?", - (p["attachment_id"], p["key"])).fetchone() - if row is None: - raise C.CommandError(f"у {p['key']} нет вложения #{p['attachment_id']} в зеркале — обновите задачу") - if row["size"] is not None and row["size"] > files.MAX_SIZE: # size NULL — размер неизвестен, ставим - raise TooLarge(f"файл больше 10 МБ ({attachments.fmt_size(row['size'])}) — скачайте в Jira") - _assign_boards(conn, cmds) - - -def _assign_boards(conn, cmds) -> None: - """Доска проекта задачи в params ensure_sprint (design #7): метки временного ключа — из его ensure_issue в пакете, - иначе из зеркала; 422 — задача вне проектов, разные доски, нет доски, канбан, тип ещё не известен.""" - temp_labels = {c["params"]["temp_key"]: c["params"].get("labels") or [] for c in cmds if c["type"] == "ensure_issue"} - found = projects.light_list(conn) - for i, c in enumerate(cmds, 1): - if c["type"] != "ensure_sprint": - continue - key = c["params"]["key"] - labels = temp_labels[key] if key in temp_labels else (mirror.labels_of(conn, key) or []) - own = [p for p in found if p["label"] in labels] - if not own: - raise C.CommandError(f"команда {i}: {key} не входит ни в один проект — доску для спринта не определить") - if len({p["board_id"] for p in own}) > 1: - raise C.CommandError(f"команда {i}: {key} в проектах {', '.join(p['label'] for p in own)} с разными досками") - p = own[0] - if p["board_id"] is None: - raise C.CommandError(f"команда {i}: у проекта {p['label']} нет доски Jira") - if p["board_type"] == "kanban": - raise C.CommandError(f"команда {i}: у проекта {p['label']} канбан-доска, спринтов нет") - if p["board_type"] is None: - raise C.CommandError(f"команда {i}: тип доски проекта {p['label']} ещё не известен — дождитесь обновления из Jira") - c["params"] = {**c["params"], "board_id": p["board_id"]} - - -def _check_project(conn, cmds, project) -> list[dict]: - """Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются. - Номер команды — как в присланном пакете.""" - if projects.get(conn, project) is None: - raise C.CommandError(f"проекта {project} нет") - others = {l.casefold() for l in projects.labels(conn) if l != project} - temps = {c["params"]["temp_key"] for c in cmds if c["type"] == "ensure_issue"} - - def own(key): - return key in temps or project in (mirror.labels_of(conn, key) or []) - - out = [] - for i, c in enumerate(cmds, 1): - t, p = c["type"], dict(c["params"]) - if t == "ensure_issue": - labels = [] - for l in p.get("labels") or []: - if l.casefold() in others: - raise C.CommandError(f"команда {i}: метка другого проекта: {l}") - l = project if l.casefold() == project.casefold() else l - if l not in labels or l != project: - labels.append(l) - if project not in labels: - labels.append(project) - p["labels"] = labels - elif t == "ensure_link": - if not (own(p["source"]) or own(p["target"])): - raise C.CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") - elif t not in ("refresh", "fetch_attachment"): # fetch_attachment — не запись в Jira, проекта не касается - if t == "ensure_fields": - if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}: - raise C.CommandError(f"команда {i}: снять метку своего проекта {project} нельзя") - added = [] - for l in p.get("labels_add") or []: - if l.casefold() in others: - raise C.CommandError(f"команда {i}: метка другого проекта: {l}") - l = project if l.casefold() == project.casefold() else l - if l not in added: - added.append(l) - if "labels_add" in p: - p["labels_add"] = added - if not own(p["key"]): - raise C.CommandError(f"команда {i}: {p['key']} не в проекте {project}") - out.append({**c, "params": p}) - return out +def _batch_hooks(cmds): + """Обработчики модуля пакета. Типы из разных модулей в одном пакете нельзя; ни одного известного типа — первый + модуль реестра: его validate выдаёт ошибку о неизвестном типе.""" + owners = [] + for c in cmds if isinstance(cmds, list) else []: + m = registry.module_of(c["type"]) if isinstance(c, dict) and isinstance(c.get("type"), str) else None + if m is not None and m not in owners: + owners.append(m) + if len(owners) > 1: + raise CommandError("команды разных модулей в одном пакете") + if owners: + return owners[0].commands + return next(m.commands for m in registry.modules() if m.commands) def create_batch(conn, title, author, cmds, files_dir=None, project=None) -> dict: - cmds = C.validate(cmds) - if project is not None: - cmds = _check_project(conn, cmds, project) - _check_setup(conn, cmds, files_dir) - keys = [] - for c in cmds: - keys += [k for k in C.referenced_keys(c) if k not in keys] - if any(c["type"] not in READ_TYPES for c in cmds) and keys: - cmds.append({"type": "refresh", "params": {"keys": keys}}) - return _insert_batch(conn, title, author, cmds, project) + h = _batch_hooks(cmds) + cmds = h.validate(cmds) + cmds = h.check_batch(conn, cmds, project, files_dir) + tail = h.tail(cmds) + if tail is not None: + cmds.append(tail) + return insert_batch(conn, title, author, cmds, project) -def _insert_batch(conn, title, author, cmds, project=None) -> dict: +def estimate(conn, types) -> int: + """Оценка длительности, с: сумма пауз типов (паузу даёт модуль-владелец типа).""" + return sum(_pause(conn, t) for t in types) + + +def _pause(conn, ctype) -> int: + h = _hooks(ctype) + return int(h.pause(conn, ctype)) if h else 0 + + +def insert_batch(conn, title, author, cmds, project=None) -> dict: """Вставка пакета без проверок и хвоста; транзакция своя (вложенная идёт через SAVEPOINT).""" - est = C.estimate([c["type"] for c in cmds], jira_settings.pacing(conn)) + est = estimate(conn, [c["type"] for c in cmds]) with db.transaction(conn): cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES (?, ?, ?, ?, ?)", (title, author or "api", db.now(), est, project)) batch_id = cur.lastrowid defined = {} for seq, c in enumerate(cmds, 1): - deps = [defined[k] for k in C.temp_keys_used(c) if k in defined] + h = _hooks(c["type"]) + deps = [defined[k] for k in (h.temp_keys_used(c) if h else []) if k in defined] cur = conn.execute("INSERT INTO command(batch_id, seq, type, params, depends_on) VALUES (?, ?, ?, ?, ?)", (batch_id, seq, c["type"], _dump(c["params"]), deps[0] if deps else None)) - if c["type"] == "ensure_issue": - defined[c["params"]["temp_key"]] = cur.lastrowid + if h and h.temp_definer and c["type"] == h.temp_definer[0]: + defined[c["params"][h.temp_definer[1]]] = cur.lastrowid return {"id": batch_id, "estimate_seconds": est, "count": len(cmds)} -def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> dict: - """Поставить скачивание вложения. Проверка «уже качается/скачано» и вставка пакета — в одной транзакции с - блокировкой записи (BEGIN IMMEDIATE): два одновременных запроса дают одну команду (решение 7). - Ответ — как attachments.get плюс command_id, batch_id. KeyError — вложения нет в зеркале; TooLarge, AgentTooOld.""" - attachment_id = str(attachment_id) - with db.transaction(conn): - info = attachments.get(conn, files_dir, attachment_id) - if info is None: - raise KeyError(attachment_id) - if info["state"] in ("ready", "fetching"): - return info - agent = conn.execute("SELECT agent_version FROM agent_status WHERE id = 1").fetchone() - v = agent["agent_version"] if agent else None - if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем - raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}") - cmds = C.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}]) - _check_setup(conn, cmds, files_dir) - _insert_batch(conn, f"Скачать вложение {info['filename']} ({info['key']})", author, cmds) - return attachments.get(conn, files_dir, attachment_id) +def has_active(conn) -> bool: + """В очереди есть команда queued или claimed.""" + return conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone() is not None + + +def agent_version(conn): + """Версия агента из его последней выдачи; None — агент не отчитывался.""" + row = conn.execute("SELECT agent_version FROM agent_status WHERE id = 1").fetchone() + return row["agent_version"] if row else None def _status_of(cmds, cancelled_at=None) -> str: @@ -233,11 +137,10 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: if b is None: return None cmds = [_cmd(r) for r in conn.execute("SELECT * FROM command WHERE batch_id = ? ORDER BY seq", (batch_id,))] - pacing = jira_settings.pacing(conn) d = dict(b) d["status"] = _status_of(cmds, b["cancelled_at"]) d["counts"] = {s: sum(1 for c in cmds if c["status"] == s) for s in sorted({c["status"] for c in cmds})} - d["remaining_seconds"] = C.estimate([c["type"] for c in cmds if c["status"] in ("queued", "claimed")], pacing) + d["remaining_seconds"] = estimate(conn, [c["type"] for c in cmds if c["status"] in ("queued", "claimed")]) if with_commands: d["commands"] = cmds return d @@ -251,23 +154,25 @@ def list_batches(conn, limit=20, project=None) -> list[dict]: # --- выдача ----------------------------------------------------------------- -def _mapping(conn, batch_id) -> dict: - """temp_key → настоящий ключ по завершённым ensure_issue пакета.""" - rows = conn.execute("SELECT json_extract(params, '$.temp_key') AS t, json_extract(result, '$.key') AS k FROM command " - "WHERE batch_id = ? AND type = 'ensure_issue' AND status IN ('done', 'confirmed_by_state')", - (batch_id,)).fetchall() +def _mapping(conn, batch_id, definer) -> dict: + """temp_key → настоящий ключ по завершённым командам-определителям пакета.""" + ctype, in_field, out_field = definer + rows = conn.execute(f"SELECT json_extract(params, '$.{in_field}') AS t, json_extract(result, '$.{out_field}') AS k " + "FROM command WHERE batch_id = ? AND type = ? AND status IN ('done', 'confirmed_by_state')", + (batch_id, ctype)).fetchall() return {r["t"]: r["k"] for r in rows if r["k"]} -def _definer_status(conn, batch_id, temp_key): - r = conn.execute("SELECT status FROM command WHERE batch_id = ? AND type = 'ensure_issue' " - "AND json_extract(params, '$.temp_key') = ?", (batch_id, temp_key)).fetchone() +def _definer_status(conn, batch_id, definer, temp_key): + ctype, in_field, _ = definer + r = conn.execute(f"SELECT status FROM command WHERE batch_id = ? AND type = ? " + f"AND json_extract(params, '$.{in_field}') = ?", (batch_id, ctype, temp_key)).fetchone() return r["status"] if r else "failed" -def _skip_reason(conn, batch_id, unresolved) -> str: +def _skip_reason(conn, batch_id, definer, unresolved) -> str: """Текст skipped для зависимой команды: отменённое создание называется отменой (O-10 #16, решение 10).""" - cancelled = [k for k in unresolved if _definer_status(conn, batch_id, k) == "cancelled"] + cancelled = [k for k in unresolved if _definer_status(conn, batch_id, definer, k) == "cancelled"] return f"создание задачи {cancelled[0]} отменено" if cancelled else f"задача {unresolved[0]} не создана" @@ -276,19 +181,18 @@ def _next(conn) -> dict | None: "WHERE c.status = 'queued' AND b.cancelled_at IS NULL ORDER BY b.created_at, b.id, c.seq").fetchall() for row in rows: cmd = _cmd(row) - needed = C.temp_keys_used(cmd) + h = _hooks(cmd["type"]) + needed = h.temp_keys_used(cmd) if h and h.temp_definer else [] if needed: - mapping = _mapping(conn, cmd["batch_id"]) + mapping = _mapping(conn, cmd["batch_id"], h.temp_definer) unresolved = [k for k in needed if k not in mapping] - if any(_definer_status(conn, cmd["batch_id"], k) not in TERMINAL for k in unresolved): + if any(_definer_status(conn, cmd["batch_id"], h.temp_definer, k) not in TERMINAL for k in unresolved): return None # создание ещё не завершено — ждём (одна в полёте) - cmd["params"] = C.substitute(cmd["type"], cmd["params"], mapping) - if cmd["type"] == "refresh": - cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not C.is_temp(k)] - unresolved = [] if cmd["params"]["keys"] else unresolved + cmd["params"] = h.substitute(cmd["type"], cmd["params"], mapping) + unresolved = h.drop_unresolved(cmd, unresolved) if unresolved: conn.execute("UPDATE command SET status = 'skipped', finished_at = ?, error = ? WHERE id = ?", - (db.now(), _skip_reason(conn, cmd["batch_id"], unresolved), cmd["id"])) + (db.now(), _skip_reason(conn, cmd["batch_id"], h.temp_definer, unresolved), cmd["id"])) continue return cmd return None @@ -306,26 +210,18 @@ def _redeliver(conn, now) -> dict | None: return cmd conn.execute("UPDATE command SET status = 'failed', finished_at = ?, error = ? WHERE id = ?", (now, f"агент не отчитался после {MAX_DELIVERIES} выдач", cmd["id"])) - if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12) - conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"])) + h = _hooks(cmd["type"]) + if h: + h.on_failed(conn, cmd, now) conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1 AND current_command_id = ?", (cmd["id"],)) log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) def deliver_params(conn, cmd, files_dir) -> dict: - """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, - если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала - (change-history). В базе params остаются без них: повтор команды проходит проверку как новая.""" - if cmd["type"] == "refresh": - out = {**cmd["params"], "boards": jira_projects.boards(conn)} - hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5) - if hist is not None: - out["history"] = hist - return out - if cmd["type"] != "ensure_attachment": - return cmd["params"] - size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None - return {**cmd["params"], "size": size} + """Параметры команды для агента: дописывает модуль-владелец типа (в базе params остаются без дописанного: повтор + команды проходит проверку как новая).""" + h = _hooks(cmd["type"]) + return h.deliver_params(conn, cmd, files_dir) if h else cmd["params"] def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=None, backup=None, files_dir=None) -> dict | None: @@ -337,13 +233,12 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non (now, None if agent_version is None else str(agent_version), settings_version, last_jira_call_at)) cmd = _redeliver(conn, now) if cmd is not None: - pause = int(jira_settings.pacing(conn).get(cmd["type"], 0)) + pause = _pause(conn, cmd["type"]) conn.execute("UPDATE command SET claimed_at = ?, pause_seconds = ?, deliveries = deliveries + 1 WHERE id = ?", (now, pause, cmd["id"])) - if cmd["type"] == "refresh": - # повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей, - # не сдвигается (M-5 #12) - conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],)) + h = _hooks(cmd["type"]) + if h: + h.on_claim(conn, cmd, now, True) conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) log.info("команда %s выдана снова (выдача %s)", cmd["id"], cmd["deliveries"] + 1) else: @@ -351,18 +246,18 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non if cmd is None: conn.execute("UPDATE agent_status SET current_command_id = NULL WHERE id = 1") return None - pause = int(jira_settings.pacing(conn).get(cmd["type"], 0)) + pause = _pause(conn, cmd["type"]) first = conn.execute("SELECT COUNT(*) AS n FROM command WHERE batch_id = ? AND status != 'queued'", (cmd["batch_id"],)).fetchone()["n"] == 0 - has_writes = conn.execute("SELECT 1 FROM command WHERE batch_id = ? AND type NOT IN ('refresh', 'fetch_attachment') LIMIT 1", - (cmd["batch_id"],)).fetchone() is not None + read = sorted(_read_types()) + has_writes = conn.execute(f"SELECT 1 FROM command WHERE batch_id = ? AND type NOT IN ({','.join('?' * len(read))}) LIMIT 1", + (cmd["batch_id"], *read)).fetchone() is not None conn.execute("UPDATE command SET status = 'claimed', claimed_at = ?, pause_seconds = ?, params = ?, deliveries = 1 " "WHERE id = ?", (now, pause, _dump(cmd["params"]), cmd["id"])) conn.execute("UPDATE agent_status SET current_command_id = ? WHERE id = 1", (cmd["id"],)) - if cmd["type"] == "refresh": - p = cmd["params"] - conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", - (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + h = _hooks(cmd["type"]) + if h: + h.on_claim(conn, cmd, now, False) if first and has_writes: to_backup = f"batch-{cmd['batch_id']}" if to_backup and backup: @@ -376,20 +271,6 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non # --- отчёты ----------------------------------------------------------------- -def is_full_refresh(conn, command_id) -> bool: - """Команда — refresh по базовой JQL (полный прогон): только он вправе сбрасывать флаг neighbor.""" - if not command_id: - return False - cmd = get_command(conn, command_id) - base = jira_projects.base_jql(conn) - return bool(base and cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") == base) - - -def record_state(conn, command_id, issues_count, links_count) -> None: - conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?", - (int(issues_count), int(links_count), command_id)) - - def report_result(conn, command_id, status, result=None, error=None, jira_calls=0, rate_limited=0, files_dir=None) -> dict: if status not in ("done", "failed"): raise ValueError("status — done или failed") @@ -400,62 +281,38 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= raise KeyError(command_id) if cmd["status"] == "queued": raise NotClaimedError(f"команда {command_id} не выдана") + h = _hooks(cmd["type"]) if cmd["status"] in TERMINAL: - # поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач — - # M-4 #12): ключ не теряем, статус не трогаем - if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done" - and result and result.get("key") and not cmd["result"]): - conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", - (_dump(result), int(jira_calls or 0), int(rate_limited or 0), command_id)) - mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) - log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат", - command_id, cmd["status"], result["key"]) + # поздний отчёт по команде, которую хаб уже закрыл (force-отмена, предел выдач): модуль может принять данные, + # статус не трогаем + if h and h.on_late_result(conn, cmd, status, result, jira_calls, rate_limited): return get_command(conn, command_id) return cmd # идемпотентно; терминальный статус (cancelled/failed) не затирается now = db.now() - fetched = None - if cmd["type"] == "fetch_attachment" and status == "done": - # файл должен лежать в хранилище с заявленным размером; иначе отчёт не принимаем (уборка успела раньше, чужой размер) - sha = result.get("file") if isinstance(result, dict) else None - real = files.size(files_dir, sha) if files_dir is not None else None - if real is None or real != result.get("size"): - status, error = "failed", f"хаб: файл {sha} не найден в хранилище"[:ERROR_LIMIT] - else: - fetched = (cmd["params"]["attachment_id"], sha, real) + if h: + status, why = h.check_result(conn, cmd, status, result, files_dir) + if why is not None: + error = why[:ERROR_LIMIT] conn.execute("UPDATE command SET status = ?, finished_at = ?, result = ?, error = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", (status, now, _dump(result) if result is not None else None, error, int(jira_calls or 0), int(rate_limited or 0), command_id)) conn.execute("UPDATE agent_status SET current_command_id = NULL, last_seen_at = ? WHERE id = 1 AND current_command_id = ?", (now, command_id)) - if cmd["type"] == "ensure_issue" and status == "done" and result and result.get("key"): - mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) - if fetched: - conn.execute("INSERT OR REPLACE INTO attachment_file(attachment_id, file, size, fetched_at) VALUES (?, ?, ?, ?)", - (*fetched, now)) - if cmd["type"] == "refresh": - conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", - (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) - if isinstance(result, dict) and "boards" in result: - jira_projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем - if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result): - conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id)) - if status == "done" and is_full_refresh(conn, command_id): - # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — - # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) - run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (command_id,)).fetchone() - if run and run["issues_count"] > 0: - conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],)) - if cmd["type"] == "refresh" and status == "done": - reconcile(conn) + if h: + h.on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) + if h: + h.after_result(conn, cmd, status) return get_command(conn, command_id) def reconcile(conn) -> list[int]: """Взятые команды без ответа, чей результат уже виден в зеркале → confirmed_by_state.""" - link_types = settings.get(conn, "link_types") or {} changed = [] for row in conn.execute("SELECT * FROM command WHERE status = 'claimed'").fetchall(): cmd = _cmd(row) - ok, result = C.check_confirmed(conn, cmd, link_types) + h = _hooks(cmd["type"]) + if h is None: + continue + ok, result = h.check_confirmed(conn, cmd) if not ok: continue with db.transaction(conn): @@ -504,8 +361,9 @@ def retry_command(conn, command_id, author, files_dir=None, project=None) -> dic raise KeyError(command_id) origin = conn.execute("SELECT project FROM batch WHERE id = ?", (cmd["batch_id"],)).fetchone()["project"] if project is not None and project != origin: - raise C.CommandError(f"команда #{command_id} не из проекта {project}") - params = {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")} + raise CommandError(f"команда #{command_id} не из проекта {project}") + h = _hooks(cmd["type"]) + params = h.retry_params(cmd) if h else cmd["params"] return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": params}], files_dir=files_dir, project=origin) @@ -523,18 +381,3 @@ def agent_view(conn) -> dict: d["redelivered"] = [dict(r) for r in conn.execute( "SELECT id, type, claimed_at, deliveries FROM command WHERE status = 'claimed' AND deliveries > 1 ORDER BY id")] return d - - -def maybe_auto_refresh(conn) -> dict | None: - jql = jira_projects.base_jql(conn) - if jql is None: - return None - minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) - if minutes <= 0 or not agent_view(conn)["online"]: - return None - if conn.execute("SELECT 1 FROM command WHERE status IN ('queued', 'claimed') LIMIT 1").fetchone(): - return None - last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"] - if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes): - return None - return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": jql}}]) diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index fe1ff7f..2c8e766 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -1,6 +1,6 @@ """Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" from ...core.modules import Module -from . import schema, settings +from . import hooks, schema, settings MODULE = Module( name="jira", @@ -10,4 +10,6 @@ MODULE = Module( validate_setting=settings.validate_setting, settings_view=settings.settings_view, on_init=settings.on_init, + commands=hooks.HOOKS, + file_refs=hooks.file_refs, ) diff --git a/src/jira_hub/modules/jira/commands.py b/src/jira_hub/modules/jira/commands.py index a924645..eac51d6 100644 --- a/src/jira_hub/modules/jira/commands.py +++ b/src/jira_hub/modules/jira/commands.py @@ -171,10 +171,6 @@ def substitute(cmd_type, params, mapping: dict) -> dict: return out -def estimate(types, pacing: dict) -> int: - return sum(int(pacing.get(t, 0)) for t in types) - - def _norm_text(value): """Значение поля для сравнения с зеркалом: переводы строки CRLF и LF не различаются (описание, правленное в UI Jira, приходит с CRLF, O-9 #16); null и пустая строка равны (решение 11).""" diff --git a/src/jira_hub/modules/jira/fetch.py b/src/jira_hub/modules/jira/fetch.py new file mode 100644 index 0000000..1050eea --- /dev/null +++ b/src/jira_hub/modules/jira/fetch.py @@ -0,0 +1,39 @@ +"""Скачивание вложения из Jira по запросу (fetch_attachment): постановка в очередь и ошибки постановки.""" +from ...core import db, queue +from ...core.errors import CommandError, HttpMapped +from . import attachments, commands + +FETCH_AGENT_VERSION = 14 # первая версия агента с fetch_attachment + + +class TooLarge(CommandError, HttpMapped): + """Вложение больше предела хаба (fetch_attachment) — API отвечает 413 too_large; для POST /batches это 422.""" + status = 413 + error = "too_large" + + +class AgentTooOld(ValueError, HttpMapped): + """Агент ещё не умеет fetch_attachment — API отвечает 409 agent_too_old.""" + status = 409 + error = "agent_too_old" + + +def request_fetch(conn, files_dir, attachment_id: str, author: str | None) -> dict: + """Поставить скачивание вложения. Проверка «уже качается/скачано» и вставка пакета — в одной транзакции с + блокировкой записи (BEGIN IMMEDIATE): два одновременных запроса дают одну команду (решение 7). + Ответ — как attachments.get плюс command_id, batch_id. KeyError — вложения нет в зеркале; TooLarge, AgentTooOld.""" + from . import hooks # внутри функции: hooks импортирует это место ради TooLarge + attachment_id = str(attachment_id) + with db.transaction(conn): + info = attachments.get(conn, files_dir, attachment_id) + if info is None: + raise KeyError(attachment_id) + if info["state"] in ("ready", "fetching"): + return info + v = queue.agent_version(conn) + if v is not None and str(v).isdigit() and int(v) < FETCH_AGENT_VERSION: # версия неизвестна — не блокируем + raise AgentTooOld(f"агент v{v} не умеет скачивать вложения — нужен v{FETCH_AGENT_VERSION}") + cmds = commands.validate([{"type": "fetch_attachment", "params": {"key": info["key"], "attachment_id": attachment_id}}]) + hooks.check_setup(conn, cmds, files_dir) + queue.insert_batch(conn, f"Скачать вложение {info['filename']} ({info['key']})", author, cmds) + return attachments.get(conn, files_dir, attachment_id) diff --git a/src/jira_hub/modules/jira/hooks.py b/src/jira_hub/modules/jira/hooks.py new file mode 100644 index 0000000..9c9d8f4 --- /dev/null +++ b/src/jira_hub/modules/jira/hooks.py @@ -0,0 +1,267 @@ +"""Смысл типов команд jira для очереди ядра: проверки постановки, паузы, отчёты, журнал синхронизаций (решения 4, 12).""" +import json +import logging + +from ...core import files, projects, queue, settings +from ...core.errors import CommandError +from ...core.modules import CommandHooks +from . import attachments, commands, history, mirror, refresh +from . import projects as jira_projects +from . import settings as jira_settings +from .fetch import TooLarge + +log = logging.getLogger(__name__) + +READ_TYPES = frozenset({"refresh", "fetch_attachment"}) # не пишут в Jira: без хвостового refresh и без резервной копии + + +# --- постановка --------------------------------------------------------------- + +def check_setup(conn, cmds, files_dir) -> None: + """Проверки постановки, которым нужны настройки хаба и хранилище файлов (bug-and-attachments, решения 6–7): 422 + до создания пакета. Номер команды — как в присланном пакете (хвостовой refresh ещё не добавлен). + files_dir=None — хранилища нет: любой файл «не загружен».""" + bug = (settings.get(conn, "issue_types") or {}).get("bug") + bug_ok = isinstance(bug, dict) and bool(bug.get("name") or bug.get("id")) + for i, c in enumerate(cmds, 1): + p = c["params"] + if c["type"] == "ensure_issue" and p["issue_type"] == "bug" and not bug_ok: + raise CommandError(f"команда {i}: нет настройки issue_types.bug") + if c["type"] == "ensure_attachment": + if not files.is_sha(p["file"]): + raise CommandError(f"команда {i}: file — sha256 из POST /files (64 символа 0-9a-f)") + if files_dir is None or not files.exists(files_dir, p["file"]): + raise CommandError(f"команда {i}: файл {p['file']} не загружен: POST /files") + if not p["filename"].strip(): + raise CommandError(f"команда {i}: filename пустой") + if "/" in p["filename"] or "\\" in p["filename"]: + raise CommandError(f"команда {i}: filename без / и \\ — имя вложения, а не путь") + if '"' in p["filename"] or any(ord(ch) < 32 or ord(ch) == 127 for ch in p["filename"]): + # кавычка, CR, LF ломают заголовок части multipart/form-data у агента (R-2) + raise CommandError(f"команда {i}: filename без кавычек и управляющих символов (CR, LF и др.)") + if c["type"] == "fetch_attachment": + if not p["attachment_id"].isdigit(): + raise CommandError(f"команда {i}: attachment_id — цифры id вложения из issue") + row = conn.execute("SELECT size FROM issue_attachment WHERE attachment_id = ? AND key = ?", + (p["attachment_id"], p["key"])).fetchone() + if row is None: + raise CommandError(f"у {p['key']} нет вложения #{p['attachment_id']} в зеркале — обновите задачу") + if row["size"] is not None and row["size"] > files.MAX_SIZE: # size NULL — размер неизвестен, ставим + raise TooLarge(f"файл больше 10 МБ ({attachments.fmt_size(row['size'])}) — скачайте в Jira") + _assign_boards(conn, cmds) + + +def _assign_boards(conn, cmds) -> None: + """Доска проекта задачи в params ensure_sprint (design #7): метки временного ключа — из его ensure_issue в пакете, + иначе из зеркала; 422 — задача вне проектов, разные доски, нет доски, канбан, тип ещё не известен.""" + temp_labels = {c["params"]["temp_key"]: c["params"].get("labels") or [] for c in cmds if c["type"] == "ensure_issue"} + found = projects.light_list(conn) + for i, c in enumerate(cmds, 1): + if c["type"] != "ensure_sprint": + continue + key = c["params"]["key"] + labels = temp_labels[key] if key in temp_labels else (mirror.labels_of(conn, key) or []) + own = [p for p in found if p["label"] in labels] + if not own: + raise CommandError(f"команда {i}: {key} не входит ни в один проект — доску для спринта не определить") + 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") + c["params"] = {**c["params"], "board_id": p["board_id"]} + + +def _check_project(conn, cmds, project) -> list[dict]: + """Проверки пакета проекта (spec commands, «Пакет проекта»): новые словари команд — входные params не мутируются. + Номер команды — как в присланном пакете.""" + if projects.get(conn, project) is None: + raise CommandError(f"проекта {project} нет") + others = {l.casefold() for l in projects.labels(conn) if l != project} + temps = {c["params"]["temp_key"] for c in cmds if c["type"] == "ensure_issue"} + + def own(key): + return key in temps or project in (mirror.labels_of(conn, key) or []) + + out = [] + for i, c in enumerate(cmds, 1): + t, p = c["type"], dict(c["params"]) + if t == "ensure_issue": + labels = [] + for l in p.get("labels") or []: + if l.casefold() in others: + raise CommandError(f"команда {i}: метка другого проекта: {l}") + l = project if l.casefold() == project.casefold() else l + if l not in labels or l != project: + labels.append(l) + if project not in labels: + labels.append(project) + p["labels"] = labels + elif t == "ensure_link": + if not (own(p["source"]) or own(p["target"])): + raise CommandError(f"команда {i}: связь {p['source']} → {p['target']} не касается проекта {project}") + 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 []}: + raise CommandError(f"команда {i}: снять метку своего проекта {project} нельзя") + added = [] + for l in p.get("labels_add") or []: + if l.casefold() in others: + raise CommandError(f"команда {i}: метка другого проекта: {l}") + l = project if l.casefold() == project.casefold() else l + if l not in added: + added.append(l) + if "labels_add" in p: + p["labels_add"] = added + if not own(p["key"]): + raise CommandError(f"команда {i}: {p['key']} не в проекте {project}") + out.append({**c, "params": p}) + return out + + +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) + return cmds + + +def tail(cmds) -> dict | None: + """Хвостовой refresh пишущего пакета: ключи из всех команд, чтобы зеркало увидело результат.""" + keys = [] + for c in cmds: + keys += [k for k in commands.referenced_keys(c) if k not in keys] + if any(c["type"] not in READ_TYPES for c in cmds) and keys: + return {"type": "refresh", "params": {"keys": keys}} + return None + + +def pause(conn, ctype) -> int: + return int(jira_settings.pacing(conn).get(ctype, 0)) + + +# --- выдача ------------------------------------------------------------------- + +def deliver_params(conn, cmd, files_dir) -> dict: + """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, + если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала + (change-history). В базе params остаются без них: повтор команды проходит проверку как новая.""" + if cmd["type"] == "refresh": + out = {**cmd["params"], "boards": jira_projects.boards(conn)} + hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5) + if hist is not None: + out["history"] = hist + return out + if cmd["type"] != "ensure_attachment": + return cmd["params"] + size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None + return {**cmd["params"], "size": size} + + +def drop_unresolved(cmd, unresolved) -> list[str]: + """refresh с неразрешёнными временными ключами выполняется по остальным (временные выбрасываются из keys); остался + хоть один ключ — неразрешённых нет. cmd["params"] — уже после подстановки.""" + if cmd["type"] == "refresh": + cmd["params"]["keys"] = [k for k in cmd["params"].get("keys", []) if not commands.is_temp(k)] + return [] if cmd["params"]["keys"] else unresolved + return unresolved + + +def on_claim(conn, cmd, now, redelivery) -> None: + if cmd["type"] != "refresh": + return + if redelivery: + # повторное выполнение загрузит то же заново: счётчики с нуля; started_at — граница пометки соседей, + # не сдвигается (M-5 #12) + conn.execute("UPDATE sync_run SET issues_count = 0, links_count = 0 WHERE command_id = ?", (cmd["id"],)) + else: + p = cmd["params"] + conn.execute("INSERT INTO sync_run(started_at, kind, query, command_id) VALUES (?, ?, ?, ?)", + (now, "jql" if p.get("jql") else "keys", p.get("jql") or ",".join(p.get("keys", [])), cmd["id"])) + + +def on_failed(conn, cmd, now) -> None: + if cmd["type"] == "refresh": # загрузка закончилась ничем — журнал синхронизаций закрыт (M-4 #12) + conn.execute("UPDATE sync_run SET finished_at = ? WHERE command_id = ?", (now, cmd["id"])) + + +# --- отчёты ------------------------------------------------------------------- + +def check_result(conn, cmd, status, result, files_dir) -> tuple[str, str | None]: + """fetch_attachment: файл должен лежать в хранилище с заявленным размером; иначе отчёт не принимаем (уборка успела + раньше, чужой размер). Ошибка None — отчёт принят как есть.""" + if cmd["type"] == "fetch_attachment" and status == "done": + sha = result.get("file") if isinstance(result, dict) else None + real = files.size(files_dir, sha) if files_dir is not None else None + if real is None or real != result.get("size"): + return "failed", f"хаб: файл {sha} не найден в хранилище" + return status, None + + +def on_result(conn, cmd, status, result, jira_calls, rate_limited, now, files_dir) -> None: + ctype = cmd["type"] + if ctype == "ensure_issue" and status == "done" and result and result.get("key"): + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + if ctype == "fetch_attachment" and status == "done": # check_result уже сверил файл и размер + conn.execute("INSERT OR REPLACE INTO attachment_file(attachment_id, file, size, fetched_at) VALUES (?, ?, ?, ?)", + (cmd["params"]["attachment_id"], result["file"], result["size"], now)) + if ctype == "refresh": + conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", + (now, int(jira_calls or 0), int(rate_limited or 0), cmd["id"])) + if isinstance(result, dict) and "boards" in result: + jira_projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем + full = status == "done" and refresh.is_full_refresh(conn, cmd["id"]) + if full and history.accepted(result): + conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, cmd["id"])) + if full: + # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — + # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) + run = conn.execute("SELECT started_at, issues_count FROM sync_run WHERE command_id = ?", (cmd["id"],)).fetchone() + if run and run["issues_count"] > 0: + conn.execute("UPDATE issue SET neighbor = 1 WHERE fetched_at < ?", (run["started_at"],)) + + +def after_result(conn, cmd, status) -> None: + if cmd["type"] == "refresh" and status == "done": + queue.reconcile(conn) + + +def on_late_result(conn, cmd, status, result, jira_calls, rate_limited) -> bool: + """Поздний отчёт о созданной задаче по команде, которую хаб уже закрыл (force-отмена — M-5, предел выдач — M-4 #12): + ключ не теряем, статус не трогаем.""" + if (cmd["status"] in ("cancelled", "failed") and cmd["type"] == "ensure_issue" and status == "done" + and result and result.get("key") and not cmd["result"]): + conn.execute("UPDATE command SET result = ?, jira_calls = ?, rate_limited = ? WHERE id = ?", + (json.dumps(result, ensure_ascii=False), int(jira_calls or 0), int(rate_limited or 0), cmd["id"])) + mirror.rename_note(conn, cmd["params"]["temp_key"], result["key"]) + log.warning("команда %s уже %s, но агент создал %s — ключ записан в результат", + cmd["id"], cmd["status"], result["key"]) + return True + return False + + +def check_confirmed(conn, cmd) -> tuple[bool, dict | None]: + return commands.check_confirmed(conn, cmd, settings.get(conn, "link_types") or {}) + + +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")} + + +def file_refs(conn) -> set[str]: + """Файлы, на которые ссылаются ensure_attachment в queued или claimed: уборка их не трогает.""" + return {r[0] for r in conn.execute("SELECT json_extract(params, '$.file') FROM command " + "WHERE type = 'ensure_attachment' AND status IN ('queued', 'claimed')")} + + +HOOKS = CommandHooks( + types=frozenset(commands.SPEC), read_types=READ_TYPES, validate=commands.validate, check_batch=check_batch, + tail=tail, pause=pause, deliver_params=deliver_params, temp_definer=("ensure_issue", "temp_key", "key"), + temp_keys_used=commands.temp_keys_used, substitute=commands.substitute, drop_unresolved=drop_unresolved, + retry_params=retry_params, check_confirmed=check_confirmed, on_claim=on_claim, on_failed=on_failed, + check_result=check_result, on_result=on_result, after_result=after_result, on_late_result=on_late_result) diff --git a/src/jira_hub/modules/jira/projects.py b/src/jira_hub/modules/jira/projects.py index 3c97813..66231c0 100644 --- a/src/jira_hub/modules/jira/projects.py +++ b/src/jira_hub/modules/jira/projects.py @@ -3,7 +3,7 @@ import logging import re from ...core import settings -from ...core.projects import ProjectError, _label_error, create, labels, validate +from ...core.projects import ProjectError, label_error, create, labels, validate log = logging.getLogger(__name__) @@ -60,7 +60,7 @@ def parse_legacy_labels(jql) -> list[str] | None: out: list[str] = [] for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): label = raw.strip().strip('"') - if _label_error(label): + if label_error(label): return None if label.casefold() not in {x.casefold() for x in out}: out.append(label) diff --git a/src/jira_hub/modules/jira/refresh.py b/src/jira_hub/modules/jira/refresh.py new file mode 100644 index 0000000..2eec202 --- /dev/null +++ b/src/jira_hub/modules/jira/refresh.py @@ -0,0 +1,39 @@ +"""Обновление зеркала из Jira (команда refresh): журнал синхронизаций, полный прогон, автообновление.""" +from datetime import timedelta + +from ...core import db, queue, settings +from . import projects + + +def is_full_refresh(conn, command_id) -> bool: + """Команда — refresh по базовой JQL (полный прогон): только он вправе сбрасывать флаг neighbor.""" + if not command_id: + return False + cmd = queue.get_command(conn, command_id) + base = projects.base_jql(conn) + return bool(base and cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") == base) + + +def record_state(conn, command_id, issues_count, links_count) -> None: + conn.execute("UPDATE sync_run SET issues_count = issues_count + ?, links_count = links_count + ? WHERE command_id = ?", + (int(issues_count), int(links_count), command_id)) + + +def maybe_auto_refresh(conn) -> dict | None: + jql = projects.base_jql(conn) + if jql is None: + return None + minutes = int(settings.get(conn, "auto_refresh_minutes") or 0) + if minutes <= 0 or not queue.agent_view(conn)["online"]: + return None + if queue.has_active(conn): + return None + last = conn.execute("SELECT MAX(started_at) AS t FROM sync_run").fetchone()["t"] + if last and db.utcnow() - db.parse(last) < timedelta(minutes=minutes): + return None + return queue.create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": jql}}]) + + +def auto_refresh_tick(hub, conn) -> None: + """Фоновая работа модуля: автообновление (подключается к tick хаба в Task 5).""" + maybe_auto_refresh(conn) diff --git a/tests/test_commands.py b/tests/test_commands.py index f9d3b57..0e30b7c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -98,7 +98,6 @@ def test_substitute_and_estimate(): p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"}, {"new:2": "A-7", "new:60": "A-9"}) assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7" - assert commands.estimate(["ensure_fields", "ensure_comment", "refresh"], {"ensure_fields": 120, "ensure_comment": 30, "refresh": 2}) == 152 @pytest.fixture diff --git a/tests/test_queue.py b/tests/test_queue.py index 8b599c9..4fdebdd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -3,7 +3,8 @@ import hashlib import pytest from jira_hub.core import db, files, projects, queue, settings -from jira_hub.modules.jira import attachments, mirror +from jira_hub.modules.jira import attachments, commands, mirror, refresh +from jira_hub.modules.jira import fetch as jira_fetch from jira_hub.modules.jira import projects as jira_projects from jira_hub.modules.jira.commands import CommandError from jira_hub.core.queue import ClaimedError @@ -56,7 +57,7 @@ def test_claim_order_pause_and_report(conn): tail = queue.claim(conn) assert tail["type"] == "refresh" and tail["pause_seconds"] == 2 assert conn.execute("SELECT kind, query FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone()["query"] == "A-1" - queue.record_state(conn, tail["id"], 1, 0) + refresh.record_state(conn, tail["id"], 1, 0) queue.report_result(conn, tail["id"], "done", jira_calls=1) run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (tail["id"],)).fetchone() assert run["finished_at"] and run["issues_count"] == 1 and run["jira_calls"] == 1 @@ -164,32 +165,32 @@ def test_reconcile_confirms_by_state(conn, add_project): def test_auto_refresh_needs_projects(conn): queue.claim(conn, agent_version=1, settings_version=1) - assert queue.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет + assert refresh.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет def test_auto_refresh_two_projects(conn, add_project): # сценарий «Два проекта» add_project(conn) add_project(conn, "STOCKS_DRRK", board_id=None) queue.claim(conn, agent_version=1, settings_version=1) - b = queue.maybe_auto_refresh(conn) + b = refresh.maybe_auto_refresh(conn) assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'} def test_auto_refresh_conditions(conn, add_project): add_project(conn) - assert queue.maybe_auto_refresh(conn) is None # агент не на связи + assert refresh.maybe_auto_refresh(conn) is None # агент не на связи queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста - b = queue.maybe_auto_refresh(conn) + b = refresh.maybe_auto_refresh(conn) assert b and queue.batch_view(conn, b["id"])["title"] == "Автообновление" - assert queue.maybe_auto_refresh(conn) is None # очередь занята + assert refresh.maybe_auto_refresh(conn) is None # очередь занята c = queue.claim(conn) queue.report_result(conn, c["id"], "done") - assert queue.maybe_auto_refresh(conn) is None # интервал не прошёл + assert refresh.maybe_auto_refresh(conn) is None # интервал не прошёл conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") - assert queue.maybe_auto_refresh(conn) is not None + assert refresh.maybe_auto_refresh(conn) is not None settings.set_(conn, "auto_refresh_minutes", 0) conn.execute("UPDATE sync_run SET started_at = '2020-01-01T00:00:00+00:00'") - assert queue.maybe_auto_refresh(conn) is None + assert refresh.maybe_auto_refresh(conn) is None def test_backup_called_for_first_write_only(conn, tmp_path): @@ -255,14 +256,14 @@ def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch, add_pr queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": ["AWPCA"]}], []) - real = queue.C.check_confirmed + real = commands.check_confirmed def racing(*a, **kw): out = real(*a, **kw) queue.cancel_command(conn, c["id"], force=True) # гонка: отмена после проверки return out - monkeypatch.setattr(queue.C, "check_confirmed", racing) + monkeypatch.setattr(commands, "check_confirmed", racing) assert queue.reconcile(conn) == [] assert queue.get_command(conn, c["id"])["status"] == "cancelled" @@ -291,7 +292,7 @@ def test_auto_refresh_waits_for_claimed_without_report(conn): queue.claim(conn) # выдана, отчёта нет conn.execute("DELETE FROM command WHERE status = 'queued'") # хвостовой refresh не мешает проверке conn.execute("UPDATE command SET claimed_at = '2020-01-01T00:00:00+00:00' WHERE status = 'claimed'") # давно без отчёта - assert queue.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята + assert refresh.maybe_auto_refresh(conn) is None # claimed без отчёта — очередь занята def test_redelivery_of_lost_claim_before_queue(conn): @@ -449,11 +450,11 @@ def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project): mirror.apply_state(conn, [{"key": "OLD", "summary": "x", "labels": []}], [], fetched_at="2020-01-01T00:00:00+00:00") queue.create_batch(conn, "полный", "claude", [{"type": "refresh", "params": {"jql": jql}}]) r = queue.claim(conn) - queue.record_state(conn, r["id"], 1, 0) + refresh.record_state(conn, r["id"], 1, 0) mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], []) assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача assert conn.execute("SELECT deliveries FROM command WHERE id = ?", (r["id"],)).fetchone()["deliveries"] == 2 - queue.record_state(conn, r["id"], 1, 0) + refresh.record_state(conn, r["id"], 1, 0) mirror.apply_state(conn, [{"key": "NEW", "summary": "y", "labels": []}], []) queue.report_result(conn, r["id"], "done") nb = {row["key"]: row["neighbor"] for row in conn.execute("SELECT key, neighbor FROM issue")} @@ -464,10 +465,10 @@ def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project): def test_redelivered_refresh_resets_counts(conn): queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) r = queue.claim(conn) - queue.record_state(conn, r["id"], 150, 40) + refresh.record_state(conn, r["id"], 150, 40) started = conn.execute("SELECT started_at FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone()["started_at"] assert queue.claim(conn)["id"] == r["id"] # отчёт потерян — довыдача - queue.record_state(conn, r["id"], 150, 40) + refresh.record_state(conn, r["id"], 150, 40) run = conn.execute("SELECT * FROM sync_run WHERE command_id = ?", (r["id"],)).fetchone() assert (run["issues_count"], run["links_count"]) == (150, 40) assert run["started_at"] == started @@ -1066,7 +1067,7 @@ def test_fetch_checks(fconn): with pytest.raises(CommandError) as e: queue.create_batch(fconn, "x", "claude", [fetch(key="B-1")]) assert str(e.value) == "у B-1 нет вложения #31337 в зеркале — обновите задачу" - with pytest.raises(queue.TooLarge) as e: + with pytest.raises(jira_fetch.TooLarge) as e: queue.create_batch(fconn, "x", "claude", [fetch("31338")]) assert str(e.value) == "файл больше 10 МБ (23,8 МБ) — скачайте в Jira" assert queue.create_batch(fconn, "x", "claude", [fetch("31339")])["count"] == 1 # size NULL — ставится @@ -1077,9 +1078,9 @@ def test_fetch_size_limit_boundary(fconn): mirror.apply_state(fconn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ {"id": "41", "filename": "eq.bin", "size": files.MAX_SIZE, "created": "2026-10-20T10:00:00.000+0300"}, {"id": "42", "filename": "over.bin", "size": files.MAX_SIZE + 1, "created": "2026-10-20T10:00:00.000+0300"}]}], []) - assert queue.request_fetch(fconn, None, "41", "claude")["state"] == "fetching" - with pytest.raises(queue.TooLarge): - queue.request_fetch(fconn, None, "42", "claude") + assert jira_fetch.request_fetch(fconn, None, "41", "claude")["state"] == "fetching" + with pytest.raises(jira_fetch.TooLarge): + jira_fetch.request_fetch(fconn, None, "42", "claude") def test_fetch_report_done_non_dict_result_fails(fconn, tmp_path): @@ -1117,7 +1118,7 @@ def test_request_fetch_once(open_db, tmp_path): def run(c): try: barrier.wait() - res.append(queue.request_fetch(c, tmp_path / "files", "31337", "x")) + res.append(jira_fetch.request_fetch(c, tmp_path / "files", "31337", "x")) except Exception as e: # noqa: BLE001 errs.append(e) @@ -1139,30 +1140,30 @@ def put_file(tmp_path, data=b"abc"): def test_request_fetch_ready(fconn, tmp_path): root, sha = put_file(tmp_path) fconn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('31337', ?, 3, ?)", (sha, db.now())) - r = queue.request_fetch(fconn, root, "31337", "x") + r = jira_fetch.request_fetch(fconn, root, "31337", "x") assert r["state"] == "ready" and r["filename"] == "ОСВ 60.xlsx" assert fconn.execute("SELECT COUNT(*) AS n FROM command").fetchone()["n"] == 0 with pytest.raises(KeyError): - queue.request_fetch(fconn, root, "999", "x") + jira_fetch.request_fetch(fconn, root, "999", "x") def test_request_fetch_agent_too_old(fconn, tmp_path): fconn.execute("UPDATE agent_status SET agent_version = '13' WHERE id = 1") - with pytest.raises(queue.AgentTooOld) as e: - queue.request_fetch(fconn, tmp_path / "files", "31337", "x") + with pytest.raises(jira_fetch.AgentTooOld) as e: + jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x") assert str(e.value) == "агент v13 не умеет скачивать вложения — нужен v14" fconn.execute("UPDATE agent_status SET agent_version = '14' WHERE id = 1") - assert queue.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" + assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" fconn.execute("UPDATE agent_status SET agent_version = NULL WHERE id = 1") - assert queue.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" + assert jira_fetch.request_fetch(fconn, tmp_path / "files", "31337", "x")["state"] == "fetching" def test_request_fetch_after_failed(fconn, tmp_path): root = tmp_path / "files" - first = queue.request_fetch(fconn, root, "31337", "x") + first = jira_fetch.request_fetch(fconn, root, "31337", "x") cmd = queue.claim(fconn) queue.report_result(fconn, cmd["id"], "failed", error="нет сети", files_dir=root) - again = queue.request_fetch(fconn, root, "31337", "x") + again = jira_fetch.request_fetch(fconn, root, "31337", "x") assert again["state"] == "fetching" and again["batch_id"] != first["batch_id"] @@ -1187,7 +1188,7 @@ def test_report_fetch_done_missing_file(fconn, tmp_path): assert cmd["status"] == "failed" and cmd["error"] == f"хаб: файл {FSHA} не найден в хранилище" assert fconn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 # после уборки файла (done → файла нет) и повторной постановки — новый пакет - assert queue.request_fetch(fconn, root, "31337", "x")["state"] == "fetching" + assert jira_fetch.request_fetch(fconn, root, "31337", "x")["state"] == "fetching" def test_report_fetch_wrong_size(fconn, tmp_path): @@ -1201,6 +1202,6 @@ def test_request_fetch_after_done_file_swept(fconn, tmp_path): root, sha = put_file(tmp_path) report_done(fconn, root, {"file": sha, "size": 3}) files.path(root, sha).unlink() # уборка файла - r = queue.request_fetch(fconn, root, "31337", "x") + r = jira_fetch.request_fetch(fconn, root, "31337", "x") assert r["state"] == "fetching" assert fconn.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 2 diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py new file mode 100644 index 0000000..976ab95 --- /dev/null +++ b/tests/test_queue_core.py @@ -0,0 +1,84 @@ +"""Ядро очереди на модуле-заглушке: механика не знает типов команд jira (hub-modules, решение 4).""" +import os +import time +from dataclasses import replace + +import pytest + +from jira_hub.core import files, queue, registry +from jira_hub.core.errors import CommandError +from jira_hub.core.modules import CommandHooks, Module +from jira_hub.modules import jira + +SHA = "a" * 64 + + +def _validate(cmds): + return [{"type": c["type"], "params": c.get("params") or {}} for c in cmds] + + +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, + 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 params, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], + check_confirmed=lambda conn, cmd: (False, None), on_claim=lambda *a: None, on_failed=lambda *a: None, + check_result=lambda conn, cmd, status, result, files_dir: (status, None), + on_result=lambda conn, cmd, status, *a: events.append(("on_result", cmd["type"], status)), + after_result=lambda conn, cmd, status: events.append(("after_result", cmd["type"], status)), + on_late_result=lambda *a: False) + return Module(name="t", commands=hooks, file_refs=lambda conn: set(refs)) + + +@pytest.fixture +def stub(monkeypatch): + events = [] + monkeypatch.setattr(registry, "_cache", [jira.MODULE, make_stub(events)]) + return events + + +def test_tail_and_estimate(open_db, stub): + conn = open_db() + b = queue.create_batch(conn, "x", "claude", [{"type": "w", "params": {}}]) + assert [c["type"] for c in queue.batch_view(conn, b["id"])["commands"]] == ["w", "r"] + assert b["estimate_seconds"] == 14 and queue.estimate(conn, ["w", "r"]) == 14 + + +def test_mixed_modules(open_db, stub): + conn = open_db() + with pytest.raises(CommandError, match="команды разных модулей в одном пакете"): + queue.create_batch(conn, "x", "claude", [{"type": "w"}, {"type": "refresh", "params": {"jql": "x"}}]) + + +def test_on_result_called(tmp_path, open_db, stub): + path = tmp_path / "hub.sqlite" + conn = open_db(path) + queue.create_batch(conn, "x", "claude", [{"type": "w", "params": {}}]) + cmd = queue.claim(conn) + seen = {} + real = stub.append + + def after(conn_, cmd_, status): + other = open_db(path) # новое соединение видит зафиксированное + seen["status"] = other.execute("SELECT status FROM command WHERE id = ?", (cmd_["id"],)).fetchone()["status"] + real(("after_result", cmd_["type"], status)) + + hooks = replace(registry.module("t").commands, after_result=after) + registry._cache[1] = replace(registry.module("t"), commands=hooks) + queue.report_result(conn, cmd["id"], "done", {}) + assert ("on_result", "w", "done") in stub and ("after_result", "w", "done") in stub + assert seen["status"] == "done" + + +def test_sweep_uses_file_refs(tmp_path, open_db, monkeypatch): + monkeypatch.setattr(registry, "_cache", [jira.MODULE, make_stub([], refs={SHA})]) + root = tmp_path / "files" + root.mkdir() + (root / SHA).write_bytes(b"x") + old = time.time() - 40 * 86400 + os.utime(root / SHA, (old, old)) + assert files.sweep(open_db(), root) == [] + assert (root / SHA).exists() diff --git a/tests/test_registry.py b/tests/test_registry.py index ca51eae..1a0a1cf 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -10,7 +10,7 @@ from jira_hub.core.modules import CommandHooks, Module CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" # временно: R-1, сокращается в Task 4–6 -PENDING = {"api.py", "cli.py", "mcp_server.py", "queue.py"} +PENDING = {"api.py", "cli.py", "mcp_server.py"} def _noop(*a, **k): -- 2.47.2 From bc4e8a288f5affb0017e4acb8a9aed510c11c248 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:23:00 +0400 Subject: [PATCH 10/23] =?UTF-8?q?test:=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D0=B5=20jira=20=D0=B2=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BE=20commands.estimate=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_queue.py | 7 +++++++ tests/test_queue_core.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_queue.py b/tests/test_queue.py index 4fdebdd..aa23cff 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1205,3 +1205,10 @@ def test_request_fetch_after_done_file_swept(fconn, tmp_path): r = jira_fetch.request_fetch(fconn, root, "31337", "x") assert r["state"] == "fetching" assert fconn.execute("SELECT COUNT(*) AS n FROM command WHERE type = 'fetch_attachment'").fetchone()["n"] == 2 + + +def test_estimate_sums_module_pauses_replaces_commands_estimate(conn): + """Замена утверждения test_commands про commands.estimate (функция ушла в ядро): сумма пауз реального модуля jira + через 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 diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py index 976ab95..d2eac4e 100644 --- a/tests/test_queue_core.py +++ b/tests/test_queue_core.py @@ -24,7 +24,7 @@ def make_stub(events, refs=()): tail=lambda cmds: {"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 params, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], + drop_unresolved=lambda cmd, unresolved: unresolved, retry_params=lambda cmd: cmd["params"], check_confirmed=lambda conn, cmd: (False, None), on_claim=lambda *a: None, on_failed=lambda *a: None, check_result=lambda conn, cmd, status, result, files_dir: (status, None), on_result=lambda conn, cmd, status, *a: events.append(("on_result", cmd["type"], status)), -- 2.47.2 From fe71adf18904f7389ab2b1503bf56382ed0195e7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:24:20 +0400 Subject: [PATCH 11/23] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20R-5,=20R-6=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md index 56e20ae..bae91d6 100644 --- a/openspec/changes/hub-modules/design.md +++ b/openspec/changes/hub-modules/design.md @@ -193,3 +193,11 @@ сохраняет ответ `/agent/settings`. Цена ошибки: нулевая. - 23.10.2026 — **R-4: `registry.check` получает маршруты ядра только в Task 5.** Обоснование: до разделения `api.py` отдельных маршрутов ядра нет. Цена ошибки: пересечение маршрутов проверяется со Task 5, не с Task 3. +- 23.10.2026 — **R-5: `retry_params` и `drop_unresolved` получают команду целиком (`cmd`), а не `params`; `link_types` + читается в `check_confirmed` модуля, а не в `reconcile` ядра; логгер позднего отчёта — + `jira_hub.modules.jira.hooks`.** Обоснование: хукам нужен тип команды; ядро не должно знать настройку `link_types`. + Цена ошибки: если кто-то фильтрует журнал по имени логгера `jira_hub.core.queue` — строка пропадёт; лишний запрос к + `setting` на каждую выданную команду (пренебрежимо). +- 23.10.2026 — **R-6: утверждение `commands.estimate(...) == 152` заменено тестом `queue.estimate` на реальном модуле + jira** (`test_estimate_sums_module_pauses_replaces_commands_estimate`). Обоснование: `commands.estimate` уходит по + плану (Task 4), семантика сохранена. Цена ошибки: нулевая. -- 2.47.2 From 0bafff9bc4bb8e2737dfc88d51be79c3d3d995e5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:32:51 +0400 Subject: [PATCH 12/23] =?UTF-8?q?refactor:=20HTTP=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D1=8F=D0=B4=D1=80=D0=B0,=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=D0=BB=D1=8F=20jira=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/api.py | 305 +++++--------------------- src/jira_hub/core/http.py | 50 +++++ src/jira_hub/core/registry.py | 2 +- src/jira_hub/modules/jira/__init__.py | 5 +- src/jira_hub/modules/jira/api.py | 211 ++++++++++++++++++ src/jira_hub/modules/jira/refresh.py | 5 +- tests/test_api.py | 5 +- tests/test_registry.py | 13 +- 8 files changed, 335 insertions(+), 261 deletions(-) create mode 100644 src/jira_hub/core/http.py create mode 100644 src/jira_hub/modules/jira/api.py diff --git a/src/jira_hub/core/api.py b/src/jira_hub/core/api.py index 55c0abd..65e11bc 100644 --- a/src/jira_hub/core/api.py +++ b/src/jira_hub/core/api.py @@ -9,16 +9,13 @@ import re import threading import time import traceback -from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -from typing import NamedTuple -from urllib.parse import parse_qs, quote, unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, files, projects, queue, relay, release, settings -from ..modules.jira import attachments, fetch, history, mirror, refresh # временно: R-1, до Task 5 -from ..modules.jira import projects as jira_projects -from .errors import CommandError +from . import backup, db, files, projects, queue, registry, relay, release, settings +from .errors import CommandError, HttpMapped +from .http import ApiError, Raw, int_group, int_param, mapped, not_found from .paths import backups_dir as default_backups_dir from .paths import secure_dir from .queue import ClaimedError @@ -33,23 +30,9 @@ ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # им NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит -MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») -class Raw(NamedTuple): - """Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache.""" - content_type: str - data: bytes - headers: dict | None = None - - -class ApiError(Exception): - def __init__(self, status, error, detail=None): - super().__init__(error) - self.status, self.error, self.detail = status, error, detail - - class Hub: def __init__(self, db_path, backups_dir=None, release_dir=None, web_dir=None, files_dir=None): self.db_path = Path(db_path) @@ -57,8 +40,8 @@ class Hub: self.backups_dir = Path(backups_dir) if backups_dir else default_backups_dir() self.release_dir = Path(release_dir) if release_dir else self.db_path.parent / "release" self.files_dir = Path(files_dir) if files_dir else self.db_path.parent / "files" - self.downloads_dir = self.db_path.parent / "downloads" # копии вложений для MCP; каталог создаёт MCP self.last_sweep = None # время прошлой уборки files/, в памяти (решение 15) + self.module_state: dict[str, dict] = {} # память модулей между тиками (имя модуля → словарь) # последнее из claim; в памяти (R-3). reported — был ли claim с запуска хаба: до него launcher_version # неизвестен, а не null (hub-hygiene-d, решение 7) self.agent_update = {"update_state": None, "launcher_version": None, "reported": False} @@ -73,19 +56,22 @@ class Hub: return db.connect(self.db_path) def tick(self): - """Один шаг фоновых работ: автообновление и уборка файлов вложений (не чаще раза в сутки). Сбой каждой - логируется и не мешает ни другой, ни следующему тику.""" + """Один шаг фоновых работ: уборка файлов ядра (не чаще раза в сутки), затем tick модулей. Сбой каждой + работы логируется и не мешает ни другой, ни следующему тику; соединение открывается при первой работе.""" + works = [("уборка файлов: сбой", self.sweep_files)] + for m in registry.modules(): + for fn in m.tick: + label = getattr(fn, "label", getattr(fn, "__name__", m.name)) + works.append((f"{label}: сбой тика", lambda conn, fn=fn: fn(self, conn))) conn = None try: - conn = self.conn() - refresh.maybe_auto_refresh(conn) - except Exception: # noqa: BLE001 - log.exception("автообновление: сбой тика") - try: - if conn is not None: - self.sweep_files(conn) - except Exception: # noqa: BLE001 - log.exception("уборка файлов: сбой") + for message, work in works: + try: + if conn is None: + conn = self.conn() + work(conn) + except Exception: # noqa: BLE001 + log.exception(message) finally: if conn is not None: conn.close() @@ -100,12 +86,6 @@ class Hub: removed = files.sweep(conn, self.files_dir, now) if removed: log.info("уборка файлов: удалено %d", len(removed)) - try: - gone = files.sweep_downloads(self.downloads_dir, now) - if gone: - log.info("уборка копий вложений: удалено %d", len(gone)) - except Exception: # noqa: BLE001 — копии MCP не должны мешать основной уборке - log.exception("уборка копий вложений: сбой") return removed def note_agent_update(self, body): @@ -133,23 +113,6 @@ class Hub: # --- обработчики: fn(hub, conn, match, query, body) -> (status, payload) ---------- -def _int(m, i=1): - return int(m.group(i)) - - -def _int_param(q, name, default): - try: - return int(q.get(name, default)) - except (TypeError, ValueError): - raise ApiError(400, "bad_param", f"{name}: ожидается целое число") - - -def _not_found(obj, what): - if obj is None: - raise ApiError(404, "not_found", what) - return obj - - def h_claim(hub, conn, m, q, body): hub.note_agent_update(body) deadline = time.monotonic() + hub.claim_wait @@ -190,13 +153,13 @@ def h_agent_settings(hub, conn, m, q, body): def h_agent_command(hub, conn, m, q, body): """Команда по id для восстановления из журнала агента: параметры — как при выдаче (с size у ensure_attachment).""" - cmd = _not_found(queue.get_command(conn, _int(m)), "command") + cmd = not_found(queue.get_command(conn, int_group(m)), "command") return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): try: - cmd = queue.report_result(conn, _int(m), body.get("status"), body.get("result"), body.get("error"), + cmd = queue.report_result(conn, int_group(m), body.get("status"), body.get("result"), body.get("error"), body.get("jira_calls", 0), body.get("rate_limited", 0), files_dir=hub.files_dir) except KeyError: raise ApiError(404, "not_found", "command") @@ -207,97 +170,6 @@ def h_agent_result(hub, conn, m, q, body): return 200, cmd -def h_agent_state(hub, conn, m, q, body): - issues = body.get("issues") or [] - try: - mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком - mirror.check_attachments(issues) - except ValueError as e: - raise ApiError(422, "bad_state", str(e)) - with db.transaction(conn): - out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], - authoritative=refresh.is_full_refresh(conn, body.get("command_id"))) - if body.get("command_id"): - refresh.record_state(conn, body["command_id"], out["issues"], out["links"]) - return 200, out - - -def h_agent_history(hub, conn, m, q, body): - changes = body.get("changes") - try: - history.check_changes(changes) # до записи: неверная пачка не применяется целиком - except ValueError as e: - raise ApiError(422, "bad_history", str(e)) - return 200, history.record_changes(conn, changes) - - -def h_known_keys(hub, conn, m, q, body): - return 200, {"keys": mirror.known_keys(conn)} - - -ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project") - - -def _project_param(conn, q) -> str | None: - """q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404.""" - label = q.get("project") - if label in (None, ""): - return None - if projects.get(conn, label) is None: - raise ApiError(404, "not_found", f"project {label}") - return label - - -def h_issues(hub, conn, m, q, body): - project = _project_param(conn, q) - filters = {k: q.get(k) for k in ISSUE_FILTERS} - filters["project"] = project - closed = settings.get(conn, "closed_statuses") - closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else [] - return 200, {"issues": mirror.list_issues(conn, filters, _int_param(q, "limit", 50), _int_param(q, "offset", 0), - closed_statuses=closed)} - - -def h_issue(hub, conn, m, q, body): - return 200, _not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue") - - -_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") - - -def _time_param(q, name, default): - raw = q.get(name) - if raw in (None, ""): - return default - value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2) - if value is None: - raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") - return value - - -def h_changes(hub, conn, m, q, body): - project = _project_param(conn, q) - until = _time_param(q, "until", db.now()) - since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat()) - if since > until: - raise ApiError(422, "bad_request", "since: позже until") - try: - kinds = history.parse_kinds(q.get("kinds")) - except ValueError as e: - raise ApiError(422, "bad_request", str(e)) - try: - limit = int(q.get("limit", 500)) - except (TypeError, ValueError): - limit = 0 - if not 1 <= limit <= 2000: - raise ApiError(422, "bad_request", "limit: целое от 1 до 2000") - return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit) - - -def h_board(hub, conn, m, q, body): - return 200, mirror.board(conn, jira_projects.base_jql(conn), _project_param(conn, q)) - - def h_app(hub, conn, m, q, body): """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" @@ -324,14 +196,6 @@ def h_root(hub, conn, m, q, body): return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"}) -def h_note_put(hub, conn, m, q, body): - fields = {k: v for k, v in body.items() if k != "updated_by"} - try: - return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") - except ValueError as e: - raise ApiError(422, "bad_note", str(e)) - - def h_batches_post(hub, conn, m, q, body): if not body.get("title") or not isinstance(body["title"], str): raise ApiError(422, "bad_batch", "title — непустая строка") @@ -346,23 +210,23 @@ def h_batches_post(hub, conn, m, q, body): try: return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir, project=body.get("project")) - except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) - raise ApiError(413, "too_large", str(e)) from None + except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2) + raise mapped(e) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_batches_get(hub, conn, m, q, body): - return 200, {"batches": queue.list_batches(conn, _int_param(q, "limit", 20), project=q.get("project") or None)} + return 200, {"batches": queue.list_batches(conn, int_param(q, "limit", 20), project=q.get("project") or None)} def h_batch_get(hub, conn, m, q, body): - return 200, _not_found(queue.batch_view(conn, _int(m)), "batch") + return 200, not_found(queue.batch_view(conn, int_group(m)), "batch") def h_batch_cancel(hub, conn, m, q, body): try: - return 200, queue.cancel_batch(conn, _int(m)) + return 200, queue.cancel_batch(conn, int_group(m)) except KeyError: raise ApiError(404, "not_found", "batch") @@ -395,48 +259,6 @@ def h_agent_files_post(hub, conn, m, q, body): return _put_file(hub, body, allow_empty=True) -def _attachment(hub, conn, m): - att = attachments.get(conn, hub.files_dir, m.group(1)) - if att is None: - raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") - return att - - -def h_attachment_get(hub, conn, m, q, body): - return 200, _attachment(hub, conn, m) - - -def h_attachment_fetch(hub, conn, m, q, body): - author = body.get("author") or "api" - if not isinstance(author, str): - raise ApiError(422, "bad_request", "author — строка") - try: - return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author) - except KeyError: - raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None - except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (решение 17) - raise ApiError(413, "too_large", str(e)) from None - except fetch.AgentTooOld as e: - raise ApiError(409, "agent_too_old", str(e)) from None - except CommandError as e: - raise ApiError(422, "bad_batch", str(e)) from None - - -def h_attachment_content(hub, conn, m, q, body): - """Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987).""" - att = _attachment(hub, conn, m) - # state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state) - row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \ - if att["state"] == "ready" else None - data = files.read(hub.files_dir, row["file"]) if row else None - if data is None: - raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание") - files.touch(hub.files_dir, row["file"]) - mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream" - return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""), - "X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"}) - - def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") @@ -445,47 +267,25 @@ def h_command_retry(hub, conn, m, q, body): if body.get("project") == "": raise ApiError(422, "bad_retry", "project: пустая строка") try: - return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir, + return 201, queue.retry_command(conn, int_group(m), body.get("author") or "api", files_dir=hub.files_dir, project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") - except fetch.TooLarge as e: # раньше CommandError: TooLarge — его подкласс (M-2) - raise ApiError(413, "too_large", str(e)) from None + except HttpMapped as e: # раньше CommandError: исключения модулей несут свой код (M-2) + raise mapped(e) from None except CommandError as e: raise ApiError(422, "bad_batch", str(e)) def h_command_cancel(hub, conn, m, q, body): try: - return 200, queue.cancel_command(conn, _int(m), bool(body.get("force"))) + return 200, queue.cancel_command(conn, int_group(m), bool(body.get("force"))) except KeyError: raise ApiError(404, "not_found", "command") except ClaimedError as e: raise ApiError(409, "claimed", e.claimed_at) -def h_refresh(hub, conn, m, q, body): - if not isinstance(body.get("author") or "api", str): - raise ApiError(422, "bad_refresh", "author — строка") - keys = body.get("keys") - if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)): - raise ApiError(422, "bad_refresh", "keys — список строк") - if body.get("jql") is not None and not isinstance(body["jql"], str): - raise ApiError(422, "bad_refresh", "jql — строка") - if body.get("full"): - jql = jira_projects.base_jql(conn) - if jql is None: - raise ApiError(422, "bad_refresh", "нет проектов") - params = {"jql": jql} - elif body.get("jql"): - params = {"jql": body["jql"]} - elif body.get("keys"): - params = {"keys": keys} - else: - raise ApiError(422, "bad_refresh", "нужно full, jql или keys") - return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) - - def h_agent_get(hub, conn, m, q, body): view = queue.agent_view(conn) rel = release.current(hub.release_dir) @@ -496,13 +296,17 @@ def h_agent_get(hub, conn, m, q, body): return 200, view -def h_sync_runs(hub, conn, m, q, body): - rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (_int_param(q, "limit", 20),)).fetchall() - return 200, {"runs": [dict(r) for r in rows]} +def _settings_view(conn) -> dict: + """Настройки ядра и модулей плюс вычисляемые поля модулей (settings_view).""" + out = settings.get_all(conn) + for mod in registry.modules(): + if mod.settings_view: + out.update(mod.settings_view(conn)) + return out def h_settings_get(hub, conn, m, q, body): - return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} + return 200, _settings_view(conn) def h_settings_put(hub, conn, m, q, body): @@ -520,7 +324,7 @@ def h_settings_put(hub, conn, m, q, body): with db.transaction(conn): for key, value in body.items(): settings.set_(conn, key, value) - return 200, {**settings.get_all(conn), "base_jql": jira_projects.base_jql(conn)} + return 200, _settings_view(conn) def h_projects_get(hub, conn, m, q, body): @@ -528,63 +332,56 @@ def h_projects_get(hub, conn, m, q, body): def h_projects_post(hub, conn, m, q, body): - """Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной - транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" + """Новый проект: проект и пакеты, которые ставят модули (on_project_created; у jira — «Новый проект <метка>») — + в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" author = body.get("author") if author is None or author == "": author = "api" elif not isinstance(author, str): raise ApiError(422, "bad_project", "author — строка") + batch_id = None try: with db.transaction(conn): project = projects.create(conn, body.get("label"), body.get("name"), body.get("board_id")) - batch = queue.create_batch(conn, f"Новый проект {project['label']}", author, - [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) + for mod in registry.modules(): + batch = mod.on_project_created(conn, project, author) if mod.on_project_created else None + if batch is not None and batch_id is None: + batch_id = batch["id"] except projects.ProjectExists as e: raise ApiError(409, "project_exists", str(e)) except projects.ProjectError as e: raise ApiError(422, "bad_project", str(e)) except CommandError as e: raise ApiError(422, "bad_batch", str(e)) - return 201, {"project": project, "batch_id": batch["id"]} + return 201, {"project": project, "batch_id": batch_id} -ROUTES = [ +CORE_ROUTES = [ ("GET", r"^/(links)?$", h_root, None), ("GET", r"^/app(?:/(.*))?$", h_app, None), ("POST", r"^/agent/claim$", h_claim, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), - ("POST", r"^/agent/state$", h_agent_state, "agent"), - ("POST", r"^/agent/history$", h_agent_history, "agent"), - ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), ("POST", r"^/agent/files$", h_agent_files_post, "agent"), - ("GET", r"^/issues$", h_issues, "client"), - ("GET", r"^/issues/([^/]+)$", h_issue, "client"), - ("GET", r"^/board$", h_board, "client"), - ("GET", r"^/changes$", h_changes, "client"), - ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("POST", r"^/batches$", h_batches_post, "client"), ("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches/(\d+)$", h_batch_get, "client"), ("POST", r"^/batches/(\d+)/cancel$", h_batch_cancel, "client"), ("POST", r"^/files$", h_files_post, "client"), - ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), - ("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"), - ("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"), ("POST", r"^/commands/(\d+)/retry$", h_command_retry, "client"), ("POST", r"^/commands/(\d+)/cancel$", h_command_cancel, "client"), - ("POST", r"^/refresh$", h_refresh, "client"), ("GET", r"^/agent$", h_agent_get, "client"), - ("GET", r"^/sync-runs$", h_sync_runs, "client"), ("GET", r"^/settings$", h_settings_get, "client"), ("PUT", r"^/settings$", h_settings_put, "client"), ("GET", r"^/projects$", h_projects_get, "client"), ("POST", r"^/projects$", h_projects_post, "client"), ] +# маршрут ядра и модуля не делят метод + шаблон: пересечение — ошибка старта; ROUTES собирается после проверки +registry.check(registry.modules(), CORE_ROUTES, settings.CORE_DEFAULTS) +ROUTES = CORE_ROUTES + [r for mod in registry.modules() for r in mod.routes] _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] diff --git a/src/jira_hub/core/http.py b/src/jira_hub/core/http.py new file mode 100644 index 0000000..1f9db6b --- /dev/null +++ b/src/jira_hub/core/http.py @@ -0,0 +1,50 @@ +"""Общее для обработчиков HTTP ядра и модулей: ошибки API, не-JSON ответ, разбор параметров.""" +from typing import NamedTuple + +from . import projects +from .errors import HttpMapped + + +class Raw(NamedTuple): + """Не-JSON ответ обработчика (веб-приложение, перенаправление, файл). headers=None — Cache-Control: no-cache.""" + content_type: str + data: bytes + headers: dict | None = None + + +class ApiError(Exception): + def __init__(self, status, error, detail=None): + super().__init__(error) + self.status, self.error, self.detail = status, error, detail + + +def int_group(m, i=1) -> int: + return int(m.group(i)) + + +def int_param(q, name, default) -> int: + try: + return int(q.get(name, default)) + except (TypeError, ValueError): + raise ApiError(400, "bad_param", f"{name}: ожидается целое число") + + +def not_found(obj, what): + if obj is None: + raise ApiError(404, "not_found", what) + return obj + + +def project_param(conn, q) -> str | None: + """q["project"] — метка существующего проекта; без параметра — None, неизвестная метка — 404.""" + label = q.get("project") + if label in (None, ""): + return None + if projects.get(conn, label) is None: + raise ApiError(404, "not_found", f"project {label}") + return label + + +def mapped(e: HttpMapped) -> ApiError: + """Исключение модуля с кодом HTTP → ответ API; ядро не знает его типа.""" + return ApiError(e.status, e.error, str(e)) diff --git a/src/jira_hub/core/registry.py b/src/jira_hub/core/registry.py index 302f1a8..11a296a 100644 --- a/src/jira_hub/core/registry.py +++ b/src/jira_hub/core/registry.py @@ -10,7 +10,7 @@ def modules() -> list[Module]: from ..modules import jira # внутри функции: ядро не зависит от модулей при импорте from . import settings mods = [jira.MODULE] - # маршруты ядра сюда попадут в Task 5, когда api перестанет быть владельцем всех маршрутов + # маршруты ядра проверяет core/api.py при сборке ROUTES: здесь их ещё нет check(mods, [], settings.CORE_DEFAULTS) _cache = mods return _cache diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index 2c8e766..d863252 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -1,6 +1,6 @@ """Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" from ...core.modules import Module -from . import hooks, schema, settings +from . import api, hooks, refresh, schema, settings MODULE = Module( name="jira", @@ -12,4 +12,7 @@ MODULE = Module( on_init=settings.on_init, commands=hooks.HOOKS, file_refs=hooks.file_refs, + routes=api.ROUTES, + tick=(refresh.auto_refresh_tick, api.downloads_tick), + on_project_created=api.on_project_created, ) diff --git a/src/jira_hub/modules/jira/api.py b/src/jira_hub/modules/jira/api.py new file mode 100644 index 0000000..487eb18 --- /dev/null +++ b/src/jira_hub/modules/jira/api.py @@ -0,0 +1,211 @@ +"""HTTP модуля jira: зеркало задач, доска, лента изменений, заметки, вложения, обновление из Jira, журнал синхронизаций. +Маршруты подключает MODULE.routes; обработчики — fn(hub, conn, match, query, body) -> (status, payload).""" +import logging +import re +import time +from datetime import timedelta +from urllib.parse import quote + +from ...core import db, files, queue, settings +from ...core.errors import CommandError, HttpMapped +from ...core.http import ApiError, Raw, int_group, int_param, mapped, not_found, project_param +from . import attachments, fetch, history, mirror, refresh +from . import projects as jira_projects + +log = logging.getLogger(__name__) + +MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить +DOWNLOADS_EVERY = 24 * 3600 # уборка downloads/ не чаще раза в сутки +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project") +_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") + + +def h_agent_state(hub, conn, m, q, body): + issues = body.get("issues") or [] + try: + mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + mirror.check_attachments(issues) + except ValueError as e: + raise ApiError(422, "bad_state", str(e)) + with db.transaction(conn): + out = mirror.apply_state(conn, issues, body.get("links") or [], body.get("neighbor_keys") or [], + authoritative=refresh.is_full_refresh(conn, body.get("command_id"))) + if body.get("command_id"): + refresh.record_state(conn, body["command_id"], out["issues"], out["links"]) + return 200, out + + +def h_agent_history(hub, conn, m, q, body): + changes = body.get("changes") + try: + history.check_changes(changes) # до записи: неверная пачка не применяется целиком + except ValueError as e: + raise ApiError(422, "bad_history", str(e)) + return 200, history.record_changes(conn, changes) + + +def h_known_keys(hub, conn, m, q, body): + return 200, {"keys": mirror.known_keys(conn)} + + +def h_issues(hub, conn, m, q, body): + project = project_param(conn, q) + filters = {k: q.get(k) for k in ISSUE_FILTERS} + filters["project"] = project + closed = settings.get(conn, "closed_statuses") + closed = [s for s in closed if isinstance(s, str)] if isinstance(closed, list) else [] + return 200, {"issues": mirror.list_issues(conn, filters, int_param(q, "limit", 50), int_param(q, "offset", 0), + closed_statuses=closed)} + + +def h_issue(hub, conn, m, q, body): + return 200, not_found(mirror.get_issue(conn, m.group(1), hub.files_dir), "issue") + + +def _time_param(q, name, default): + raw = q.get(name) + if raw in (None, ""): + return default + value = history.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2) + if value is None: + raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") + return value + + +def h_changes(hub, conn, m, q, body): + project = project_param(conn, q) + until = _time_param(q, "until", db.now()) + since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat()) + if since > until: + raise ApiError(422, "bad_request", "since: позже until") + try: + kinds = history.parse_kinds(q.get("kinds")) + except ValueError as e: + raise ApiError(422, "bad_request", str(e)) + try: + limit = int(q.get("limit", 500)) + except (TypeError, ValueError): + limit = 0 + if not 1 <= limit <= 2000: + raise ApiError(422, "bad_request", "limit: целое от 1 до 2000") + return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit) + + +def h_board(hub, conn, m, q, body): + return 200, mirror.board(conn, jira_projects.base_jql(conn), project_param(conn, q)) + + +def h_note_put(hub, conn, m, q, body): + fields = {k: v for k, v in body.items() if k != "updated_by"} + try: + return 200, mirror.set_note(conn, m.group(1), fields, body.get("updated_by") or "api") + except ValueError as e: + raise ApiError(422, "bad_note", str(e)) + + +def _attachment(hub, conn, m): + att = attachments.get(conn, hub.files_dir, m.group(1)) + if att is None: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") + return att + + +def h_attachment_get(hub, conn, m, q, body): + return 200, _attachment(hub, conn, m) + + +def h_attachment_fetch(hub, conn, m, q, body): + author = body.get("author") or "api" + if not isinstance(author, str): + raise ApiError(422, "bad_request", "author — строка") + try: + return 200, fetch.request_fetch(conn, hub.files_dir, m.group(1), author) + except KeyError: + raise ApiError(404, "no_attachment", f"вложения #{m.group(1)} нет в зеркале — обновите задачу") from None + except HttpMapped as e: # раньше CommandError: TooLarge — его подкласс (решение 17) + raise mapped(e) from None + except CommandError as e: + raise ApiError(422, "bad_batch", str(e)) from None + + +def h_attachment_content(hub, conn, m, q, body): + """Содержимое скачанного вложения; mime — из вложения, негодный → octet-stream; имя — filename* (RFC 5987).""" + att = _attachment(hub, conn, m) + # state == ready означает, что запись attachment_file есть и файл в хранилище на месте (attachments.state) + row = conn.execute("SELECT file FROM attachment_file WHERE attachment_id = ?", (m.group(1),)).fetchone() \ + if att["state"] == "ready" else None + data = files.read(hub.files_dir, row["file"]) if row else None + if data is None: + raise ApiError(404, "not_fetched", "вложение не скачано — запросите скачивание") + files.touch(hub.files_dir, row["file"]) + mime = att["mime_type"] if isinstance(att["mime_type"], str) and MIME_RE.fullmatch(att["mime_type"]) else "application/octet-stream" + return 200, Raw(mime, data, {"Content-Disposition": "attachment; filename*=UTF-8''" + quote(att["filename"], safe=""), + "X-Content-Type-Options": "nosniff", "Cache-Control": "no-cache"}) + + +def h_refresh(hub, conn, m, q, body): + if not isinstance(body.get("author") or "api", str): + raise ApiError(422, "bad_refresh", "author — строка") + keys = body.get("keys") + if keys is not None and (not isinstance(keys, list) or not all(isinstance(k, str) and k for k in keys)): + raise ApiError(422, "bad_refresh", "keys — список строк") + if body.get("jql") is not None and not isinstance(body["jql"], str): + raise ApiError(422, "bad_refresh", "jql — строка") + if body.get("full"): + jql = jira_projects.base_jql(conn) + if jql is None: + raise ApiError(422, "bad_refresh", "нет проектов") + params = {"jql": jql} + elif body.get("jql"): + params = {"jql": body["jql"]} + elif body.get("keys"): + params = {"keys": keys} + else: + raise ApiError(422, "bad_refresh", "нужно full, jql или keys") + return 201, queue.create_batch(conn, "Обновление", body.get("author") or "api", [{"type": "refresh", "params": params}]) + + +def h_sync_runs(hub, conn, m, q, body): + rows = conn.execute("SELECT * FROM sync_run ORDER BY id DESC LIMIT ?", (int_param(q, "limit", 20),)).fetchall() + return 200, {"runs": [dict(r) for r in rows]} + + +ROUTES = ( + ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("POST", r"^/agent/history$", h_agent_history, "agent"), + ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), + ("GET", r"^/issues$", h_issues, "client"), + ("GET", r"^/issues/([^/]+)$", h_issue, "client"), + ("GET", r"^/board$", h_board, "client"), + ("GET", r"^/changes$", h_changes, "client"), + ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), + ("GET", r"^/attachments/(\d+)$", h_attachment_get, "client"), + ("POST", r"^/attachments/(\d+)/fetch$", h_attachment_fetch, "client"), + ("GET", r"^/attachments/(\d+)/content$", h_attachment_content, "client"), + ("POST", r"^/refresh$", h_refresh, "client"), + ("GET", r"^/sync-runs$", h_sync_runs, "client"), +) + + +# --- фоновые работы и события ядра ------------------------------------------------ + +def downloads_tick(hub, conn, now=None) -> None: + """Уборка копий вложений для MCP (<каталог базы>/downloads) не чаще раза в сутки; время прошлой — в памяти хаба.""" + state = hub.module_state.setdefault("jira", {}) + now = time.time() if now is None else now + last = state.get("downloads_at") + if last is not None and now - last < DOWNLOADS_EVERY: + return + state["downloads_at"] = now + gone = files.sweep_downloads(hub.db_path.parent / "downloads", now) + if gone: + log.info("уборка копий вложений: удалено %d", len(gone)) + + +downloads_tick.label = "уборка копий вложений" + + +def on_project_created(conn, project, author) -> dict | None: + """Проект создан (в той же транзакции): пакет «Новый проект <метка>» — один refresh по базовой JQL.""" + return queue.create_batch(conn, f"Новый проект {project['label']}", author, + [{"type": "refresh", "params": {"jql": jira_projects.base_jql(conn)}}]) diff --git a/src/jira_hub/modules/jira/refresh.py b/src/jira_hub/modules/jira/refresh.py index 2eec202..7989a97 100644 --- a/src/jira_hub/modules/jira/refresh.py +++ b/src/jira_hub/modules/jira/refresh.py @@ -35,5 +35,8 @@ def maybe_auto_refresh(conn) -> dict | None: def auto_refresh_tick(hub, conn) -> None: - """Фоновая работа модуля: автообновление (подключается к tick хаба в Task 5).""" + """Фоновая работа модуля: автообновление (Module.tick).""" maybe_auto_refresh(conn) + + +auto_refresh_tick.label = "автообновление" diff --git a/tests/test_api.py b/tests/test_api.py index 009af3a..8da70e5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,6 +13,7 @@ import pytest from jira_hub.core import api, db, files, projects, queue, release, settings from jira_hub.core.client import HubClient, HubError +from jira_hub.modules.jira import api as jira_api from jira_hub.modules.jira.commands import CommandError @@ -1184,7 +1185,7 @@ def test_attachment_fetch_agent_too_old_409(hub): def test_sweep_files_downloads(hub): h = hub["hub"] - d = h.downloads_dir / "31337" + d = h.db_path.parent / "downloads" / "31337" d.mkdir(parents=True) (d / "a.png").write_bytes(b"x") old = time.time() - 31 * 86400 @@ -1192,7 +1193,7 @@ def test_sweep_files_downloads(hub): os.utime(d / "a.png", (old, old)) conn = h.conn() try: - h.sweep_files(conn) + jira_api.downloads_tick(h, conn) finally: conn.close() assert not d.exists() diff --git a/tests/test_registry.py b/tests/test_registry.py index 1a0a1cf..d302046 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -9,8 +9,8 @@ from jira_hub.core.modules import CommandHooks, Module CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" -# временно: R-1, сокращается в Task 4–6 -PENDING = {"api.py", "cli.py", "mcp_server.py"} +# временно: R-1, сокращается в Task 6 +PENDING = {"cli.py", "mcp_server.py"} def _noop(*a, **k): @@ -48,6 +48,15 @@ def test_route_conflict(): registry.check([m], core, {}) +def test_routes_assembled(): + from jira_hub.core import api + from jira_hub.modules import jira + pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731 + assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(jira.MODULE.routes) + assert not pairs(api.CORE_ROUTES) & pairs(jira.MODULE.routes) + assert len(api.ROUTES) == len(api.CORE_ROUTES) + len(jira.MODULE.routes) + + def test_module_of(monkeypatch): a = Module(name="a", commands=_hooks({"t1"})) monkeypatch.setattr(registry, "_cache", [a]) -- 2.47.2 From 36f6bd6075bd9675a9f86dcb87ba27c7dd35fcf2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:40:01 +0400 Subject: [PATCH 13/23] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20R-7,=20R-8=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md index bae91d6..aa86f26 100644 --- a/openspec/changes/hub-modules/design.md +++ b/openspec/changes/hub-modules/design.md @@ -201,3 +201,11 @@ - 23.10.2026 — **R-6: утверждение `commands.estimate(...) == 152` заменено тестом `queue.estimate` на реальном модуле jira** (`test_estimate_sums_module_pauses_replaces_commands_estimate`). Обоснование: `commands.estimate` уходит по плану (Task 4), семантика сохранена. Цена ошибки: нулевая. +- 23.10.2026 — **R-7: `Hub.tick` — сначала уборка файлов ядра, затем `tick` модулей по порядку; метка сбоя работы — атрибут + `label` функции; суточная уборка `downloads/` — работа модуля jira с временем прошлой в `hub.module_state["jira"]`.** + Обоснование: ядро не знает про автообновление и `downloads/`; раньше автообновление шло первым — теперь после + `files.sweep`, который короткий. Цена ошибки: если `sweep` станет долгим, автообновление сдвинется; метка в атрибуте + функции — неявный контракт (можно оформить полем `Module`). +- 23.10.2026 — **R-8: `POST /projects` отдаёт `batch_id` первого модуля, вернувшего пакет (`null`, если никто).** Обоснование: + модуль один; формат ответа прежний. Цена ошибки: при нескольких модулях с пакетами «Новый проект» остальные не видны в + ответе — решит change модуля wiki. -- 2.47.2 From 751ae885db93ee9fc5b2e4c8128a24e0f6ff15dc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:41:27 +0400 Subject: [PATCH 14/23] =?UTF-8?q?test:=20=D1=82=D0=B8=D0=BA=20=D1=83=D0=B1?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20downloads/=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=20=D0=B2=20=D1=81=D1=83=D1=82=D0=BA=D0=B8=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_api.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 8da70e5..06dd59e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -753,6 +753,26 @@ def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): assert len(calls) == 2 +def test_tick_sweeps_downloads_once_a_day(hub): + h = hub["hub"] + old = time.time() - 31 * 86400 + + def put_old(name): + d = h.db_path.parent / "downloads" / name + d.mkdir(parents=True) + (d / "a.png").write_bytes(b"x") + os.utime(d / "a.png", (old, old)) + os.utime(d, (old, old)) + return d + + first = put_old("1") + h.tick() + assert not first.exists() and "downloads_at" in h.module_state["jira"] + second = put_old("2") + h.tick() + assert second.exists() # второй тик в те же сутки — без уборки + + def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog, add_project): _add_project(hub, add_project) def broken(*args, **kwargs): -- 2.47.2 From 6d1e59587588a4d121c0f859ed59205cf005cb66 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:51:11 +0400 Subject: [PATCH 15/23] =?UTF-8?q?refactor:=20MCP=20=D0=B8=20CLI=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D0=BC;=20jira-hub?= =?UTF-8?q?=20mcp=20[=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C]=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/cli.py | 26 +- src/jira_hub/core/mcp.py | 146 +++++++ src/jira_hub/modules/jira/__init__.py | 12 +- src/jira_hub/modules/jira/cli.py | 23 ++ .../mcp_server.py => modules/jira/mcp.py} | 300 +++++--------- tests/test_baseline.py | 5 +- tests/test_cli.py | 22 +- tests/test_layout.py | 6 +- tests/test_mcp.py | 370 +++++++++--------- tests/test_registry.py | 4 +- 10 files changed, 495 insertions(+), 419 deletions(-) create mode 100644 src/jira_hub/core/mcp.py create mode 100644 src/jira_hub/modules/jira/cli.py rename src/jira_hub/{core/mcp_server.py => modules/jira/mcp.py} (71%) diff --git a/src/jira_hub/core/cli.py b/src/jira_hub/core/cli.py index 9ef1c35..93b6d6d 100644 --- a/src/jira_hub/core/cli.py +++ b/src/jira_hub/core/cli.py @@ -1,11 +1,10 @@ -"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), import (CSV-пакет), mcp (stdio), tokens, -agent publish (версия агента для самообновления).""" +"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens, +agent publish (версия агента для самообновления) и подкоманды модулей (jira: import).""" import argparse import json import sys from . import db, paths, registry, settings -from ..modules.jira import importer def main(argv=None) -> int: @@ -17,10 +16,12 @@ def main(argv=None) -> int: p = sub.add_parser("serve", help="запустить HTTP API") p.add_argument("--host", default="0.0.0.0") p.add_argument("--port", type=int) - p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") - p.add_argument("folder") - p = sub.add_parser("mcp", help="MCP-сервер по stdio") + p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)") + p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)") p.add_argument("--project", help="метка проекта, к которому привязана сессия") + for m in registry.modules(): + if m.cli_register: + m.cli_register(sub) sub.add_parser("tokens", help="показать токены агента и клиента") p = sub.add_parser("agent", help="версия агента для самообновления") agent_sub = p.add_subparsers(dest="agent_cmd", required=True) @@ -47,9 +48,13 @@ def main(argv=None) -> int: api.serve(hub, args.host, port) return 0 if args.cmd == "mcp": - from . import mcp_server - mcp_server.run(args.project) - return 0 + from . import mcp + return mcp.run(args.module, args.project) + for m in registry.modules(): + if m.cli_run: + rc = m.cli_run(args) + if rc is not None: + return rc conn = db.connect(paths.db_path()) settings.ensure_defaults(conn) @@ -69,9 +74,6 @@ def main(argv=None) -> int: print(line) _print_tokens(conn) return 0 - if args.cmd == "import": - print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) - return 0 if args.cmd == "tokens": _print_tokens(conn) return 0 diff --git a/src/jira_hub/core/mcp.py b/src/jira_hub/core/mcp.py new file mode 100644 index 0000000..5bd68d5 --- /dev/null +++ b/src/jira_hub/core/mcp.py @@ -0,0 +1,146 @@ +"""Ядро MCP хаба: общие инструменты (projects, project_create), охрана и привязка к проекту сессии, запуск сервера модуля +по stdio (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не +исключение. Инструменты модулей лежат в modules/<имя>/mcp.py и подключают общие через register_core_tools.""" +import asyncio +import functools +import inspect +import json +import sys +from pathlib import Path + +from . import registry +from .client import HubClient, HubError, HubUnavailable + +OFFLINE = "хаб не запущен: jira-hub serve" +AUTHOR = "claude" +NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)" +HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19) +PROJECT: str | None = None # привязка сессии, задаётся run(module_name, project) + + +def unknown_project(label) -> str: + return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json" + + +def mcp_json(label: str) -> dict: + return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp", + "--project", label]}}} + + +def client() -> HubClient: + return HubClient.from_home() + + +def _hub_error(e: HubError) -> str: + return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") + + +def guard(fn): + """Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции.""" + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def awrapper(*args, **kwargs): + try: + return await fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return _hub_error(e) + return awrapper + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except HubUnavailable: + return OFFLINE + except HubError as e: + return _hub_error(e) + return wrapper + + +def unbound_or_unknown(required: bool) -> str | None: + """None — инструмент можно вызывать. Без привязки: required → NOT_BOUND без запросов к хабу. С привязкой: метка + сверяется с GET /projects при каждом вызове (сбой хаба поднимается к guard и становится OFFLINE).""" + if PROJECT is None: + return NOT_BOUND if required else None + labels = [p["label"] for p in client().get("/projects")["projects"]] + return None if PROJECT in labels else unknown_project(PROJECT) + + +def scoped(required: bool): + """Привязка инструмента к проекту сессии (spec mcp). Ставится под guard: тот превращает сбой хаба в текст.""" + def deco(fn): + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def awrapper(*args, **kwargs): + refusal = await asyncio.to_thread(unbound_or_unknown, required) + return refusal if refusal is not None else await fn(*args, **kwargs) + return awrapper + + @functools.wraps(fn) + def wrapper(*args, **kwargs): + refusal = unbound_or_unknown(required) + return refusal if refusal is not None else fn(*args, **kwargs) + return wrapper + return deco + + +def cut(text, n=80): + text = (text or "").replace("\n", " ") + return text if len(text) <= n else text[: n - 1] + "…" + + +def value(v): + return "—" if v in (None, "", []) else str(v) + + +def fmt_projects(items, current: str | None) -> str: + if not items: + return "проектов нет" + # долг hub-modules: проекты → модуль jira (change wiki) + lines = ["метка | название | доска | тип доски | задач"] + for p in items: + lines.append(" | ".join([p["label"], p["name"], value(p.get("board_id")), p.get("board_type") or "неизвестен", + str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else "")) + return "\n".join(lines) + + +@guard +def projects() -> str: + """Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.""" + return fmt_projects(client().get("/projects")["projects"], PROJECT) + + +@guard +def project_create(label: str, name: str, board_id: int | None = None) -> str: # долг hub-modules: проекты → модуль jira (change wiki) + """Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент + .mcp.json для репозитория проекта.""" + body = {"label": label, "name": name, "author": AUTHOR} + if board_id is not None: + body["board_id"] = board_id + out = client().post("/projects", body) + p = out["project"] + return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n" + "Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: " + "/mcp → reconnect, одобрите сервер проекта.\n" + + json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2)) + + +def register_core_tools(mcp) -> None: + """Общие инструменты на сервере модуля: идут последними (порядок списка инструментов — часть контракта).""" + mcp.tool()(projects) + mcp.tool()(project_create) + + +def run(module_name: str, project: str | None = None) -> int: + """Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует.""" + global PROJECT + m = registry.module(module_name) + if m is None or m.mcp_server is None: + names = ", ".join(x.name for x in registry.modules() if x.mcp_server) + print(f"неизвестный модуль {module_name}; модули: {names}", file=sys.stderr) + return 2 + PROJECT = project + m.mcp_server().run(transport="stdio") + return 0 diff --git a/src/jira_hub/modules/jira/__init__.py b/src/jira_hub/modules/jira/__init__.py index d863252..8591fe2 100644 --- a/src/jira_hub/modules/jira/__init__.py +++ b/src/jira_hub/modules/jira/__init__.py @@ -1,6 +1,13 @@ """Модуль jira: зеркало задач Jira, очередь команд агенту, вложения, журнал изменений.""" from ...core.modules import Module -from . import api, hooks, refresh, schema, settings +from . import api, cli, hooks, refresh, schema, settings + + +def mcp_server(): + """FastMCP модуля: импорт лениво, пока сервер не нужен.""" + from . import mcp + return mcp.mcp + MODULE = Module( name="jira", @@ -15,4 +22,7 @@ MODULE = Module( routes=api.ROUTES, tick=(refresh.auto_refresh_tick, api.downloads_tick), on_project_created=api.on_project_created, + mcp_server=mcp_server, + cli_register=cli.cli_register, + cli_run=cli.cli_run, ) diff --git a/src/jira_hub/modules/jira/cli.py b/src/jira_hub/modules/jira/cli.py new file mode 100644 index 0000000..f3aa3e5 --- /dev/null +++ b/src/jira_hub/modules/jira/cli.py @@ -0,0 +1,23 @@ +"""Подкоманды модуля jira для jira-hub: import (CSV-пакет admin/jira-sync в заметки).""" +import json + +from ...core import db, paths, settings +from . import importer + + +def cli_register(sub) -> None: + p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") + p.add_argument("folder") + + +def cli_run(args) -> int | None: + """None — подкоманда не модуля.""" + if args.cmd != "import": + return None + conn = db.connect(paths.db_path()) + settings.ensure_defaults(conn) + try: + print(json.dumps(importer.import_package(conn, args.folder), ensure_ascii=False, indent=1)) + finally: + conn.close() + return 0 diff --git a/src/jira_hub/core/mcp_server.py b/src/jira_hub/modules/jira/mcp.py similarity index 71% rename from src/jira_hub/core/mcp_server.py rename to src/jira_hub/modules/jira/mcp.py index 1a6093c..1d276a8 100644 --- a/src/jira_hub/core/mcp_server.py +++ b/src/jira_hub/modules/jira/mcp.py @@ -1,9 +1,8 @@ -"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). -Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" +"""MCP-инструменты модуля jira (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). +Общие охрана, привязка к проекту и инструменты projects/project_create — в core/mcp.py; состояние (клиент хаба, +привязка сессии) читается через core в момент вызова.""" import asyncio import base64 -import functools -import inspect import json import os import stat @@ -14,125 +13,32 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP -from . import paths -from ..modules.jira.attachments import fmt_size -from .client import HubClient, HubError, HubUnavailable -from ..modules.jira.commands import describe, is_temp -from .files import MAX_SIZE +from ...core import mcp as core +from ...core import paths +from ...core.client import HubError +from ...core.files import MAX_SIZE +from .attachments import fmt_size +from .commands import describe, is_temp -OFFLINE = "хаб не запущен: jira-hub serve" POLL_SECONDS = 5 -AUTHOR = "claude" BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" -NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)" -HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19) -PROJECT: str | None = None # привязка сессии, задаётся run(project) mcp = FastMCP("jira-hub") -def unknown_project(label) -> str: - return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json" - - -def mcp_json(label: str) -> dict: - return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp", - "--project", label]}}} - - -def _client() -> HubClient: - return HubClient.from_home() - - -def _hub_error(e: HubError) -> str: - return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ") - - -def guard(fn): - """Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции.""" - if inspect.iscoroutinefunction(fn): - @functools.wraps(fn) - async def awrapper(*args, **kwargs): - try: - return await fn(*args, **kwargs) - except HubUnavailable: - return OFFLINE - except HubError as e: - return _hub_error(e) - return awrapper - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - try: - return fn(*args, **kwargs) - except HubUnavailable: - return OFFLINE - except HubError as e: - return _hub_error(e) - return wrapper - - -def _unbound_or_unknown(required: bool) -> str | None: - """None — инструмент можно вызывать. Без привязки: required → NOT_BOUND без запросов к хабу. С привязкой: метка - сверяется с GET /projects при каждом вызове (сбой хаба поднимается к guard и становится OFFLINE).""" - if PROJECT is None: - return NOT_BOUND if required else None - labels = [p["label"] for p in _client().get("/projects")["projects"]] - return None if PROJECT in labels else unknown_project(PROJECT) - - -def scoped(required: bool): - """Привязка инструмента к проекту сессии (spec mcp). Ставится под guard: тот превращает сбой хаба в текст.""" - def deco(fn): - if inspect.iscoroutinefunction(fn): - @functools.wraps(fn) - async def awrapper(*args, **kwargs): - refusal = await asyncio.to_thread(_unbound_or_unknown, required) - return refusal if refusal is not None else await fn(*args, **kwargs) - return awrapper - - @functools.wraps(fn) - def wrapper(*args, **kwargs): - refusal = _unbound_or_unknown(required) - return refusal if refusal is not None else fn(*args, **kwargs) - return wrapper - return deco - - -# --- форматирование -------------------------------------------------------------- - -def _cut(text, n=80): - text = (text or "").replace("\n", " ") - return text if len(text) <= n else text[: n - 1] + "…" - - -def _v(value): - return "—" if value in (None, "", []) else str(value) - - -def fmt_projects(items, current: str | None) -> str: - if not items: - return "проектов нет" - lines = ["метка | название | доска | тип доски | задач"] - for p in items: - lines.append(" | ".join([p["label"], p["name"], _v(p.get("board_id")), p.get("board_type") or "неизвестен", - str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else "")) - return "\n".join(lines) - - def fmt_issues(issues) -> str: if not issues: return "задач нет" lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] for i in issues: - lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")), - _v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))])) + lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")), + core.value(i.get("assignee")), core.value(i.get("sprint")), core.value(i.get("fact_status"))])) return "\n".join(lines) def fmt_attachment_line(a: dict, d: dict) -> str: """Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано.""" aid = str(a["attachment_id"]) - line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {_v(a.get('author'))}, {_v(a.get('created'))}" + line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {core.value(a.get('author'))}, {core.value(a.get('created'))}" marks = [] if aid in (d.get("description_attachment_ids") or []): marks.append("в описании") @@ -173,10 +79,10 @@ def safe_name(filename: str, attachment_id: str) -> str: def fmt_issue(d) -> str: head = [f"{d['key']} — {d.get('summary')}", - f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " - f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}", - f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " - f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"] + f"тип: {core.value(d.get('issue_type'))}; статус: {core.value(d.get('status'))}; приоритет: {core.value(d.get('priority'))}; " + f"исполнитель: {core.value(d.get('assignee'))}; срок: {core.value(d.get('duedate'))}; план: {core.value(d.get('planned_start'))}…{core.value(d.get('planned_end'))}", + f"эпик: {core.value(d.get('epic_key'))}; спринт: {core.value(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " + f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {core.value(d.get('jira_updated'))}; прочитано: {core.value(d.get('fetched_at'))}"] links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] note = d.get("note") or {} @@ -186,7 +92,7 @@ def fmt_issue(d) -> str: if comments: shown = [] for c in comments[:5]: - shown.append(f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}") + shown.append(f" #{c.get('comment_id')} {core.value(c.get('author'))} {core.value(c.get('created'))}: {core.cut(c.get('body'), 300)}") files_line = _files_line(c, d.get("attachments") or []) if files_line: shown.append(" " + files_line) @@ -210,16 +116,16 @@ def _day(value, text) -> str: """Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—».""" if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit(): return f"{value[8:10]}.{value[5:7]}" - return _v(text) + return core.value(text) def fmt_comment(key, c, atts=None) -> str: """Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили. atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:».""" - head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}" + head = f"{key} #{c.get('comment_id')} {core.value(c.get('author'))}, создан {core.value(c.get('created'))}" if c.get("updated") and c.get("updated") != c.get("created"): head += f", изменён {c['updated']}" - out = head + "\n" + _v(c.get("body")) + out = head + "\n" + core.value(c.get("body")) by_id = {str(a["attachment_id"]): a for a in atts or []} refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id] if refs: @@ -237,17 +143,17 @@ def fmt_event(e: dict, today) -> str: when = "—" kind, frm, to = e.get("kind"), e.get("from"), e.get("to") if kind == "comment": - what = "комментарий: " + _cut(to, 200) + what = "комментарий: " + core.cut(to, 200) elif kind == "status": - what = f"статус «{_v(frm)}» → «{_v(to)}»" + what = f"статус «{core.value(frm)}» → «{core.value(to)}»" elif kind == "description": what = "описание изменено" elif kind == "dates": what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → " f"{_day(e.get('to_value'), to)}") else: - what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»" - return f"{when} {_v(e.get('author'))}: {what}" + what = f"{e.get('field')}: «{core.cut(frm) if frm else '—'}» → «{core.cut(to) if to else '—'}»" + return f"{when} {core.value(e.get('author'))}: {what}" def fmt_changes(data: dict, now: datetime) -> str: @@ -262,7 +168,7 @@ def fmt_changes(data: dict, now: datetime) -> str: for e in events: groups.setdefault(e["key"], []).append(e) for key, items in groups.items(): - lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}") + lines.append(f"{key} {core.cut(items[0].get('summary')) or '—'} — {core.value(items[0].get('status'))}") lines += [" " + fmt_event(e, now.date()) for e in items] if data.get("truncated"): lines.append(f"показаны последние {len(events)} событий, сузьте период") @@ -285,17 +191,17 @@ def fmt_batch(b) -> str: def fmt_agent(a) -> str: state = "онлайн" if a.get("online") else "офлайн" cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" - out = [f"агент {state} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(a.get('last_seen_at'))}); {cur}; " + out = [f"агент {state} (версия {core.value(a.get('agent_version'))}, настройки v{core.value(a.get('settings_version'))}, последний контакт {core.value(a.get('last_seen_at'))}); {cur}; " f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"] if a.get("warning"): out.append("внимание: " + a["warning"]) for s in a.get("redelivered", []): out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") rel = a.get("release") - out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет") + out.append(f"публикация: v{rel['version']} от {core.value(rel.get('published_at'))}" if rel else "публикации нет") us = a.get("update_state") if us: - out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(us.get('to_version'))}") + out.append(f"обновление: {us.get('state')} v{core.value(us.get('from_version'))} → v{core.value(us.get('to_version'))}") lv = a.get("launcher_version") if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") @@ -309,34 +215,34 @@ def fmt_agent(a) -> str: # --- инструменты ----------------------------------------------------------------- @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" - data = _client().get("/issues", project=PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) return fmt_issues(data["issues"]) @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) def issue(key: str) -> str: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5 последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком (номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment.""" - return fmt_issue(_client().get(f"/issues/{key}")) + return fmt_issue(core.client().get(f"/issues/{key}")) @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) def comment(key: str, comment_id: str) -> str: """Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»).""" cid = comment_id.lstrip("#") - d = _client().get(f"/issues/{key}") + d = core.client().get(f"/issues/{key}") for c in d.get("comments") or []: if c.get("comment_id") == cid: return fmt_comment(key, c, d.get("attachments")) @@ -367,15 +273,15 @@ def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: """Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое. Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600, по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ скачиваются только в Jira.""" - client = _client() + client = core.client() wait = min(max(int(wait_seconds), 1), 600) d = await asyncio.to_thread(client.get, f"/issues/{key}") atts = d.get("attachments") or [] @@ -391,7 +297,7 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: att = found[0] aid = str(att["attachment_id"]) try: - st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": AUTHOR}) + st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": core.AUTHOR}) except HubError as e: if e.status == 413: return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»" @@ -405,10 +311,10 @@ async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str: return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); " "вызовите attachment ещё раз — вторая команда не поставится") if st.get("state") != "ready": - return f"не удалось скачать: {_v(st.get('error'))}" + return f"не удалось скачать: {core.value(st.get('error'))}" data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content") path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data) - return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {_v(att.get('mime_type'))}, задача {key}") + return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {core.value(att.get('mime_type'))}, задача {key}") def _local_if_naive(value: str) -> str: @@ -423,7 +329,7 @@ def _local_if_naive(value: str) -> str: @mcp.tool() -@guard +@core.guard def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: """Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из @@ -432,7 +338,7 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: return "либо hours, либо since" if hours is not None and hours <= 0: return "hours — число больше 0" - refusal = _unbound_or_unknown(required=not key) # с key — любая задача, как issue + refusal = core.unbound_or_unknown(required=not key) # с key — любая задача, как issue if refusal is not None: return refusal now = datetime.now().astimezone() @@ -440,24 +346,24 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: since = (now - timedelta(hours=hours)).isoformat() elif not since: since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() - data = _client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, - project=None if key else PROJECT) + data = core.client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, + project=None if key else core.PROJECT) return fmt_changes(data, now) @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, rest_text: str | None = None, owner_note: str | None = None) -> str: """Правка служебных заметок задачи (только переданные поля).""" fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, rest_text=rest_text, owner_note=owner_note).items() if v is not None} - fields["updated_by"] = AUTHOR + fields["updated_by"] = core.AUTHOR if not is_temp(key): # новая задача пакета проекта — своя по построению - if PROJECT not in (_client().get(f"/issues/{key}").get("labels") or []): - return f"{key} не в проекте {PROJECT}" - note = _client().put(f"/issues/{key}/note", fields) + if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []): + return f"{key} не в проекте {core.PROJECT}" + note = core.client().put(f"/issues/{key}/note", fields) return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) @@ -527,8 +433,8 @@ def _doc(text: str): @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. Параметры по типам (необязательные — в [ ]): """ + describe() + """ @@ -536,29 +442,29 @@ def _doc(text: str): ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: - client = _client() + client = core.client() prepared = _attach_files(client, commands) if isinstance(prepared, str): return prepared - b = client.post("/batches", {"title": title, "author": AUTHOR, "project": PROJECT, "commands": prepared}) + b = client.post("/batches", {"title": title, "author": core.AUTHOR, "project": core.PROJECT, "commands": prepared}) return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def batch_status(id: int) -> str: """Состояние пакета и его команд.""" - return fmt_batch(_client().get(f"/batches/{id}")) + return fmt_batch(core.client().get(f"/batches/{id}")) @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) async def batch_wait(id: int, timeout_minutes: int = 10) -> str: """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 - client = _client() + client = core.client() while True: b = await asyncio.to_thread(client.get, f"/batches/{id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: @@ -567,114 +473,88 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str: @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def batches(limit: int = 10) -> str: """Последние пакеты со статусом.""" - items = _client().get("/batches", project=PROJECT, limit=limit)["batches"] + items = core.client().get("/batches", project=core.PROJECT, limit=limit)["batches"] return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) def command_retry(id: int) -> str: """Повторить команду — новый пакет с теми же параметрами.""" - b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR, "project": PROJECT}) + b = core.client().post(f"/commands/{id}/retry", {"author": core.AUTHOR, "project": core.PROJECT}) return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def batch_cancel(id: int) -> str: """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — в ответе подсказка (POST /commands/N/cancel с force).""" - return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) + return fmt_batch(core.client().post(f"/batches/{id}/cancel", {})) @mcp.tool() -@guard -@scoped(True) +@core.guard +@core.scoped(True) async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; wait=true — дождаться результата (до 30 мин).""" body = {"full": True} if full or not keys else {"keys": keys} - body["author"] = AUTHOR - b = await asyncio.to_thread(_client().post, "/refresh", body) + body["author"] = core.AUTHOR + b = await asyncio.to_thread(core.client().post, "/refresh", body) return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def agent_status() -> str: """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, состояние самообновления и запускатель.""" - return fmt_agent(_client().get("/agent")) + return fmt_agent(core.client().get("/agent")) @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def sync_log(limit: int = 10) -> str: """Журнал обновлений зеркала.""" - runs = _client().get("/sync-runs", limit=limit)["runs"] + runs = core.client().get("/sync-runs", limit=limit)["runs"] def journal(r): at = r.get("history_at") return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет" - return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " + return "\n".join(f"#{r['id']} {r['kind']} {core.cut(r.get('query'), 60)}: {r['started_at']} → {core.value(r.get('finished_at'))}, " f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, " f"{journal(r)}" for r in runs) or "обновлений не было" @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def settings_get(key: str = "") -> str: """Настройки хаба (все или одна по ключу).""" - data = _client().get("/settings") + data = core.client().get("/settings") return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1) @mcp.tool() -@guard -@scoped(False) +@core.guard +@core.scoped(False) def settings_set(key: str, value: str) -> str: """Записать настройку: value — JSON. Поднимает settings_version.""" try: parsed = json.loads(value) except ValueError as e: return f"значение не JSON: {e}" - out = _client().put("/settings", {key: parsed}) + out = core.client().put("/settings", {key: parsed}) return f"settings_version = {out['settings_version']}" -@mcp.tool() -@guard -def projects() -> str: - """Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен.""" - return fmt_projects(_client().get("/projects")["projects"], PROJECT) - - -@mcp.tool() -@guard -def project_create(label: str, name: str, board_id: int | None = None) -> str: - """Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент - .mcp.json для репозитория проекта.""" - body = {"label": label, "name": name, "author": AUTHOR} - if board_id is not None: - body["board_id"] = board_id - out = _client().post("/projects", body) - p = out["project"] - return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n" - "Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: " - "/mcp → reconnect, одобрите сервер проекта.\n" - + json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2)) - - -def run(project: str | None = None) -> None: - global PROJECT - PROJECT = project - mcp.run(transport="stdio") +core.register_core_tools(mcp) diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 7b09b31..1c3daa9 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -5,7 +5,8 @@ import json import os from pathlib import Path -from jira_hub.core import api, db, mcp_server, settings +from jira_hub.core import api, db, settings +from jira_hub.modules.jira import mcp as jira_mcp FIXTURES = Path(__file__).parent / "fixtures" / "baseline" @@ -20,7 +21,7 @@ def _check(name, snapshot): def test_mcp_tools(): - tools = asyncio.run(mcp_server.mcp.list_tools()) + tools = asyncio.run(jira_mcp.mcp.list_tools()) _check("mcp_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} for t in tools]) diff --git a/tests/test_cli.py b/tests/test_cli.py index b2fe0ec..5dcd27c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,10 +66,22 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] -def test_mcp_project_option(monkeypatch): - from jira_hub.core import mcp_server +def test_mcp_default_module(monkeypatch): + from jira_hub.core import mcp as core_mcp + from jira_hub.modules.jira import mcp as jira_mcp seen = [] - monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project)) + monkeypatch.setattr(core_mcp, "PROJECT", None) + monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT))) assert cli.main(["mcp", "--project", "AWPCA"]) == 0 - assert cli.main(["mcp"]) == 0 - assert seen == ["AWPCA", None] + assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0 + assert seen == [("stdio", "AWPCA"), ("stdio", "AWPCA")] + + +def test_mcp_unknown_module(monkeypatch, capsys): + from jira_hub.modules.jira import mcp as jira_mcp + seen = [] + monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport)) + assert cli.main(["mcp", "nope"]) == 2 + err = capsys.readouterr().err + assert "неизвестный модуль nope" in err and "jira" in err + assert seen == [] diff --git a/tests/test_layout.py b/tests/test_layout.py index 2671470..d3bf289 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -8,7 +8,7 @@ import jira_hub def test_fresh_imports(): r = subprocess.run( - [sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp_server, jira_hub.cli"], + [sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp, jira_hub.modules.jira.mcp, jira_hub.cli"], capture_output=True, text=True) assert r.returncode == 0, r.stderr @@ -19,5 +19,5 @@ def test_web_app_path(): def test_hub_root(): - from jira_hub.core import mcp_server - assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() + from jira_hub.core import mcp + assert (mcp.HUB_ROOT / "pyproject.toml").is_file() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 8dd0f61..b4572e1 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -9,7 +9,9 @@ from types import SimpleNamespace import pytest -from jira_hub.core import api, files, mcp_server, settings +from jira_hub.core import api, files, settings +from jira_hub.core import mcp as core_mcp +from jira_hub.modules.jira import mcp as jmcp from jira_hub.modules.jira import commands from jira_hub.core.client import HubClient @@ -25,61 +27,61 @@ def live(tmp_path, monkeypatch, add_project): srv = api.make_server(h, "127.0.0.1", 0) threading.Thread(target=srv.serve_forever, daemon=True).start() base = f"http://127.0.0.1:{srv.server_port}" - monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) - monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + monkeypatch.setattr(core_mcp, "client", lambda: HubClient(base, tok["client"])) + monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05) + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])} srv.shutdown() srv.server_close() def test_offline_message(monkeypatch): - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") - monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) - assert mcp_server.issues() == mcp_server.OFFLINE - assert mcp_server.agent_status() == mcp_server.OFFLINE + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert jmcp.issues() == core_mcp.OFFLINE + assert jmcp.agent_status() == core_mcp.OFFLINE def test_tools_roundtrip(live): live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема раз", "status": "Готово", "duedate": "2026-10-23", "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) - out = mcp_server.issues(sprint="Спринт 2") + out = jmcp.issues(sprint="Спринт 2") assert "A-1" in out and "Тема раз" in out and "ivanov" in out - assert "В работе" in mcp_server.notes_set("A-1", fact_status="В работе") - detail = mcp_server.issue("A-1") + assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе") + detail = jmcp.issue("A-1") assert "fact_status: В работе" in detail and "Готово" in detail - created = mcp_server.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) + created = jmcp.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) batch_id = int(created.split("#")[1].split()[0]) assert "2 команд" in created and "мин" in created - bad = mcp_server.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) + bad = jmcp.batch_create("x", [{"type": "ensure_fields", "params": {"key": "A-1"}}]) assert bad.startswith("ошибка хаба 422") cmd = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "HTTP 400 поле"}) tail = live["agent"].post("/agent/claim", {})["command"] live["agent"].post(f"/agent/commands/{tail['id']}/result", {"status": "done"}) - waited = asyncio.run(mcp_server.batch_wait(batch_id, timeout_minutes=1)) + waited = asyncio.run(jmcp.batch_wait(batch_id, timeout_minutes=1)) assert "done" in waited and "failed" in waited and "HTTP 400" in waited - assert f"#{batch_id}" in mcp_server.batches(limit=5) - assert "Повтор команды" in mcp_server.command_retry(cmd["id"]) - assert "cancelled" in mcp_server.batch_cancel(batch_id + 1) - r = asyncio.run(mcp_server.refresh(full=True)) + assert f"#{batch_id}" in jmcp.batches(limit=5) + assert "Повтор команды" in jmcp.command_retry(cmd["id"]) + assert "cancelled" in jmcp.batch_cancel(batch_id + 1) + r = asyncio.run(jmcp.refresh(full=True)) assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", r) - assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(mcp_server.refresh(keys=["A-1"], wait=False))) - assert "онлайн" in mcp_server.agent_status() - assert "120" in mcp_server.settings_get("pacing") - assert "settings_version" in mcp_server.settings_set("pacing", json.dumps({"refresh": 1})) - assert mcp_server.settings_set("tokens", '"x"').startswith("ошибка хаба 422") + assert re.fullmatch(r"пакет #\d+ «Обновление» поставлен", asyncio.run(jmcp.refresh(keys=["A-1"], wait=False))) + assert "онлайн" in jmcp.agent_status() + assert "120" in jmcp.settings_get("pacing") + assert "settings_version" in jmcp.settings_set("pacing", json.dumps({"refresh": 1})) + assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422") def test_formatters(): - assert mcp_server.fmt_issues([]) == "задач нет" - text = mcp_server.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, + assert jmcp.fmt_issues([]) == "задач нет" + text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, "sprint": None, "fact_status": None}]) assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text - assert "очередь" in mcp_server.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, + assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "settings_version": None}) - text = mcp_server.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, + text = jmcp.fmt_agent({"online": True, "agent_version": "2", "current_command_id": 40, "rate_limited_24h": 0, "redelivered": [{"id": 40, "type": "ensure_fields", "claimed_at": "2026-10-13T10:00:00+00:00", "deliveries": 2}], "warning": None, "last_seen_at": "2026-10-13T10:00:00+00:00", "settings_version": 3}) @@ -87,7 +89,7 @@ def test_formatters(): def test_settings_set_bad_json(live): - assert mcp_server.settings_set("pacing", "{не json").startswith("значение не JSON") + assert jmcp.settings_set("pacing", "{не json").startswith("значение не JSON") def _batch(status): @@ -116,25 +118,25 @@ def _fake_time(monkeypatch, step=1.0): async def sleep(_): clock["sleeps"] += 1 - monkeypatch.setattr(mcp_server, "time", SimpleNamespace(monotonic=monotonic)) - monkeypatch.setattr(mcp_server, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) + monkeypatch.setattr(jmcp, "time", SimpleNamespace(monotonic=monotonic)) + monkeypatch.setattr(jmcp, "asyncio", SimpleNamespace(sleep=sleep, to_thread=asyncio.to_thread)) return clock def test_batch_wait_polls_until_done(monkeypatch): fake = FakeClient(["running", "running", "done"]) - monkeypatch.setattr(mcp_server, "_client", lambda: fake) + monkeypatch.setattr(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch) - out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=10)) - assert out == mcp_server.fmt_batch(_batch("done")) and "таймаут" not in out + out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=10)) + assert out == jmcp.fmt_batch(_batch("done")) and "таймаут" not in out assert fake.calls == 3 and clock["sleeps"] == 2 def test_batch_wait_timeout(monkeypatch): fake = FakeClient(["running"]) - monkeypatch.setattr(mcp_server, "_client", lambda: fake) + monkeypatch.setattr(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch, step=30.0) # 1 минута = 2 шага монотонных часов - out = asyncio.run(mcp_server.batch_wait(7, timeout_minutes=1)) + out = asyncio.run(jmcp.batch_wait(7, timeout_minutes=1)) assert "таймаут ожидания" in out assert 1 < fake.calls < 10 and clock["sleeps"] == fake.calls - 1 @@ -142,47 +144,47 @@ def test_batch_wait_timeout(monkeypatch): @pytest.mark.parametrize("asked,minutes", [(0, 1), (1000, 60)]) def test_batch_wait_clamps_timeout(monkeypatch, asked, minutes): fake = FakeClient(["running"]) - monkeypatch.setattr(mcp_server, "_client", lambda: fake) + monkeypatch.setattr(core_mcp, "client", lambda: fake) clock = _fake_time(monkeypatch, step=60.0) # каждый вызов часов = +1 минута - asyncio.run(mcp_server.batch_wait(7, timeout_minutes=asked)) + asyncio.run(jmcp.batch_wait(7, timeout_minutes=asked)) # первый вызов часов (1 мин) задаёт старт; дедлайн = старт + minutes; выход, когда now >= дедлайна assert fake.calls == minutes def test_client_created_once_per_wait(monkeypatch): fake, made = FakeClient(["running", "running", "done"]), [] - monkeypatch.setattr(mcp_server, "_client", lambda: made.append(1) or fake) + monkeypatch.setattr(core_mcp, "client", lambda: made.append(1) or fake) _fake_time(monkeypatch) - asyncio.run(mcp_server.batch_wait(7)) + asyncio.run(jmcp.batch_wait(7)) assert len(made) == 1 def test_sync_log_content(monkeypatch): runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None, "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] - monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) - assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет" + monkeypatch.setattr(core_mcp, "client", lambda: FakeClient(["done"], runs)) + assert jmcp.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет" def test_async_tools_are_coroutines_and_registered(): - assert asyncio.iscoroutinefunction(mcp_server.batch_wait) and asyncio.iscoroutinefunction(mcp_server.refresh) - tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} + assert asyncio.iscoroutinefunction(jmcp.batch_wait) and asyncio.iscoroutinefunction(jmcp.refresh) + tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())} assert {"batch_wait", "refresh"} <= set(tools) assert tools["refresh"].inputSchema["properties"]["wait"]["default"] is False def test_async_guard_offline(monkeypatch): - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") - monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) - assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE - assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert asyncio.run(jmcp.batch_wait(1)) == core_mcp.OFFLINE + assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE def test_batch_wait_does_not_block_event_loop(monkeypatch): """Пока идёт ожидание, другая задача event loop продолжает выполняться.""" fake = FakeClient(["running", "running", "done"]) - monkeypatch.setattr(mcp_server, "_client", lambda: fake) - monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.01) + monkeypatch.setattr(core_mcp, "client", lambda: fake) + monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.01) async def scenario(): ticks = [] @@ -192,7 +194,7 @@ def test_batch_wait_does_not_block_event_loop(monkeypatch): ticks.append(1) await asyncio.sleep(0.005) - out, _ = await asyncio.gather(mcp_server.batch_wait(7), ticker()) + out, _ = await asyncio.gather(jmcp.batch_wait(7), ticker()) return out, ticks out, ticks = asyncio.run(scenario()) @@ -203,12 +205,12 @@ def test_fmt_batch_warns_about_claimed(): b = {"id": 9, "title": "x", "author": "claude", "created_at": "2026-10-14T10:00:00+00:00", "status": "running", "remaining_seconds": 120, "counts": {"claimed": 1, "cancelled": 1}, "commands": [], "still_claimed": [{"id": 30, "type": "ensure_fields"}]} - text = mcp_server.fmt_batch(b) + text = jmcp.fmt_batch(b) assert "команда #30 уже у агента и будет выполнена" in text and "POST /commands/30/cancel" in text def test_batch_create_mentions_labels(): - doc = mcp_server.batch_create.__doc__ or "" + doc = jmcp.batch_create.__doc__ or "" assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc assert "AWPCA" not in doc @@ -216,7 +218,7 @@ def test_batch_create_mentions_labels(): def test_batch_create_description_lists_params_from_spec(): """Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44).""" - tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())} + tools = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())} desc = tools["batch_create"].description for ctype, spec in commands.SPEC.items(): line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:")) @@ -234,13 +236,13 @@ def test_batch_create_description_lists_params_from_spec(): def test_fmt_agent_update_lines(): base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3} - text = mcp_server.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, + text = jmcp.fmt_agent({**base, "release": {"version": 7, "sha256": "s", "published_at": "2026-10-14T19:00:00+00:00"}, "update_state": {"state": "rolled_back", "from_version": 6, "to_version": 7}, "launcher_version": 1, "update_hint": "откат с v7 на v6: код 1 через 3 с"}) assert "публикация: v7 от 2026-10-14T19:00:00+00:00" in text assert "обновление: rolled_back v6 → v7" in text assert "запускатель: v1" in text and "откат с v7 на v6: код 1 через 3 с" in text - text = mcp_server.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) + text = jmcp.fmt_agent({**base, "release": None, "update_state": None, "launcher_version": None, "update_hint": None}) assert "публикации нет" in text and "без запускателя" in text and "обновление:" not in text @@ -250,18 +252,18 @@ def test_fmt_agent_launcher_unknown_after_restart(): "warning": None, "last_seen_at": "2026-10-15T10:00:00+00:00", "settings_version": 3, "release": {"version": 10, "sha256": "s", "published_at": "2026-10-15T09:00:00+00:00"}, "update_state": None, "launcher_version": None, "update_hint": None} - text = mcp_server.fmt_agent({**base, "agent_reported": False}) + text = jmcp.fmt_agent({**base, "agent_reported": False}) assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in text assert "без запускателя" not in text and "агент запущен без run.py" not in text - assert "без запускателя" in mcp_server.fmt_agent({**base, "agent_reported": True}) - assert "без запускателя" in mcp_server.fmt_agent(base) # старый хаб: поля agent_reported нет + assert "без запускателя" in jmcp.fmt_agent({**base, "agent_reported": True}) + assert "без запускателя" in jmcp.fmt_agent(base) # старый хаб: поля agent_reported нет def test_agent_status_right_after_hub_start(live): """Спека mcp «Статус агента сразу после перезапуска хаба».""" - assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in mcp_server.agent_status() + assert "запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)" in jmcp.agent_status() live["agent"].post("/agent/claim", {"agent_version": 9, "launcher_version": 2}) - text = mcp_server.agent_status() + text = jmcp.agent_status() assert "запускатель: v2" in text and "неизвестно" not in text @@ -273,17 +275,17 @@ def _detail(**over): def test_fmt_issue_comments(): - assert "комментариев нет" in mcp_server.fmt_issue(_detail()) + assert "комментариев нет" in jmcp.fmt_issue(_detail()) comments = [{"comment_id": str(n), "author": f"Автор {n}", "created": f"2026-10-1{n}T12:00:00.000+0300", "updated": None, "body": ("д" * 500 if n == 7 else f"текст {n}\nстрока")} for n in range(7, 0, -1)] - text = mcp_server.fmt_issue(_detail(comments=comments)) + text = jmcp.fmt_issue(_detail(comments=comments)) assert "комментарии (7):" in text assert "д" * 299 + "…" in text and "д" * 300 not in text # самый новый — первым, обрезан до 300 assert "Автор 3" in text and "Автор 2" not in text # 5 последних: 7…3 assert " #7 Автор 7 2026-10-17T12:00:00.000+0300: ддд" in text and " #3 Автор 3 " in text # номер — для comment (#80) assert "ещё 2" in text assert text.index("заметки:") < text.index("комментарии (7):") < text.index("команды:") - no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, + no_author = jmcp.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None, "updated": None, "body": ""}])) assert "комментарии (1):" in no_author and "ещё" not in no_author @@ -291,10 +293,10 @@ def test_fmt_issue_comments(): def test_fmt_comment(): """Один комментарий целиком (#80): переносы строк сохраняются, «изменён» — только если правили.""" body = "первая строка\n" + "т" * 1000 - text = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", + text = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": "golov", "created": "2026-10-20T10:00:00.000+0300", "updated": "2026-10-21T09:00:00.000+0300", "body": body}) assert text == ("A-1 #12 golov, создан 2026-10-20T10:00:00.000+0300, изменён 2026-10-21T09:00:00.000+0300\n" + body) - same = mcp_server.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300", + same = jmcp.fmt_comment("A-1", {"comment_id": "12", "author": None, "created": "2026-10-20T10:00:00.000+0300", "updated": "2026-10-20T10:00:00.000+0300", "body": ""}) assert same == "A-1 #12 —, создан 2026-10-20T10:00:00.000+0300\n—" @@ -307,14 +309,14 @@ def test_comment_tool(live, monkeypatch): {"id": "102", "author": "ivanov", "body": "коротко", "created": "2026-10-21T10:00:00.000+0300"}]}], "links": []}) - assert "#101 golov" in mcp_server.issue("A-1") and long not in mcp_server.issue("A-1") - out = mcp_server.comment("A-1", "101") + assert "#101 golov" in jmcp.issue("A-1") and long not in jmcp.issue("A-1") + out = jmcp.comment("A-1", "101") assert out.startswith("A-1 #101 golov, создан 2026-10-20T10:00:00.000+0300\n") and out.endswith(long) - assert mcp_server.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue - assert mcp_server.comment("A-1", "999") == "у A-1 нет комментария #999" - assert mcp_server.comment("A-404", "1").startswith("ошибка хаба 404") - monkeypatch.setattr(mcp_server, "PROJECT", None) - assert mcp_server.comment("A-1", "101") == mcp_server.NOT_BOUND + assert jmcp.comment("A-1", "#102").endswith("\nкоротко") # номер можно передать как в issue + assert jmcp.comment("A-1", "999") == "у A-1 нет комментария #999" + assert jmcp.comment("A-404", "1").startswith("ошибка хаба 404") + monkeypatch.setattr(core_mcp, "PROJECT", None) + assert jmcp.comment("A-1", "101") == core_mcp.NOT_BOUND BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", @@ -323,7 +325,7 @@ BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bu def _batch_commands(out): batch_id = int(out.split("#")[1].split()[0]) - return mcp_server._client().get(f"/batches/{batch_id}")["commands"] + return core_mcp.client().get(f"/batches/{batch_id}")["commands"] def _write(path, data): @@ -333,11 +335,11 @@ def _write(path, data): def test_batch_create_bug_with_local_file(live, tmp_path): """Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename.""" - mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, + jmcp.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"}, "bug": {"name": "Ошибка", "id": None}})) f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx" f.write_bytes(b"PK\x03\x04 xlsx") - out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", + out = jmcp.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) assert "3 команд" in out cmds = _batch_commands(out) @@ -351,7 +353,7 @@ def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) (tmp_path / "скрин.png").write_bytes(b"png") - out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { + out = jmcp.batch_create("скрин", [{"type": "ensure_attachment", "params": { "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(), "filename": "Экран 1.png"} @@ -370,9 +372,9 @@ def test_batch_create_bad_path(live, tmp_path, make, expected): src = tmp_path / "src" src.mkdir() p = make(src) - out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) + out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}]) assert out == expected.format(p=p) - assert mcp_server._client().get("/batches")["batches"] == [] + assert core_mcp.client().get("/batches")["batches"] == [] assert list((tmp_path / "files").iterdir()) == [] @@ -383,7 +385,7 @@ def test_batch_create_unreadable_file(live, tmp_path): try: if os.access(f, os.R_OK): pytest.skip("файл читается и без прав (root)") - out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) + out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}]) assert out == f"нет прав на чтение: {f}" finally: f.chmod(0o600) @@ -392,7 +394,7 @@ def test_batch_create_unreadable_file(live, tmp_path): def test_batch_create_path_and_file(live, tmp_path): f = tmp_path / "a.xlsx" f.write_bytes(b"a") - out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": { + out = jmcp.batch_create("x", [{"type": "ensure_attachment", "params": { "key": "A-1", "path": str(f), "file": "0" * 64}}]) assert out == "у ensure_attachment либо path, либо file" assert list((tmp_path / "files").iterdir()) == [] @@ -402,7 +404,7 @@ def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path): good = tmp_path / "a.xlsx" good.write_bytes(b"a") missing = tmp_path / "нет.xlsx" - out = mcp_server.batch_create("x", [ + out = jmcp.batch_create("x", [ {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}}, {"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}]) assert out == f"файл не найден: {missing}" @@ -413,117 +415,117 @@ def test_batch_create_hub_422_after_upload(live, tmp_path): """Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»).""" f = tmp_path / "a.xlsx" f.write_bytes(b"a") - out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) + out = jmcp.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}]) assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug" - assert mcp_server._client().get("/batches")["batches"] == [] + assert core_mcp.client().get("/batches")["batches"] == [] def test_batch_create_description_mentions_path(): - desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description + desc = {t.name: t for t in asyncio.run(jmcp.mcp.list_tools())}["batch_create"].description assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc assert "issue_type (task|epic|bug)" in desc assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» - monkeypatch.setattr(mcp_server, "PROJECT", None) + monkeypatch.setattr(core_mcp, "PROJECT", None) calls = [] real = HubClient.call monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k)) - for out in (mcp_server.issues(), mcp_server.issue("A-1"), mcp_server.notes_set("A-1", work_kind="x"), - mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): - assert out == mcp_server.NOT_BOUND + for out in (jmcp.issues(), jmcp.issue("A-1"), jmcp.notes_set("A-1", work_kind="x"), + jmcp.batch_create("x", []), jmcp.command_retry(1), asyncio.run(jmcp.refresh(full=True))): + assert out == core_mcp.NOT_BOUND assert calls == [] - for out in (mcp_server.batches(), mcp_server.sync_log(), mcp_server.settings_get(), asyncio.run(mcp_server.batch_wait(1))): - assert out != mcp_server.NOT_BOUND # не зависящие от проекта — работают без привязки - assert "AWPCA" in mcp_server.projects() - assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки + for out in (jmcp.batches(), jmcp.sync_log(), jmcp.settings_get(), asyncio.run(jmcp.batch_wait(1))): + assert out != core_mcp.NOT_BOUND # не зависящие от проекта — работают без привязки + assert "AWPCA" in core_mcp.projects() + assert jmcp.agent_status().startswith("агент ") # служебные инструменты работают без привязки def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте» - monkeypatch.setattr(mcp_server, "PROJECT", "STOKS") - assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" - assert mcp_server.agent_status().startswith("проект STOKS не заведён") - assert asyncio.run(mcp_server.batch_wait(1)).startswith("проект STOKS не заведён") - assert "AWPCA" in mcp_server.projects() + monkeypatch.setattr(core_mcp, "PROJECT", "STOKS") + assert jmcp.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" + assert jmcp.agent_status().startswith("проект STOKS не заведён") + assert asyncio.run(jmcp.batch_wait(1)).startswith("проект STOKS не заведён") + assert "AWPCA" in core_mcp.projects() def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён» - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") - monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) - assert mcp_server.issues() == mcp_server.OFFLINE - assert mcp_server.agent_status() == mcp_server.OFFLINE - assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE - assert mcp_server.projects() == mcp_server.OFFLINE + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + monkeypatch.setattr(core_mcp, "client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert jmcp.issues() == core_mcp.OFFLINE + assert jmcp.agent_status() == core_mcp.OFFLINE + assert asyncio.run(jmcp.refresh(full=True)) == core_mcp.OFFLINE + assert core_mcp.projects() == core_mcp.OFFLINE def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки» live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]}, {"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []}) - assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues() - out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) + assert "A-1" in jmcp.issues() and "S-1" not in jmcp.issues() + out = jmcp.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}") assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"] - assert mcp_server.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" - assert "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues - assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача - assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") + assert jmcp.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" + assert "work_kind: x" in jmcp.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues + assert "S-1" in jmcp.issue("S-1") # явный ключ — любая задача + assert jmcp.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") def test_bound_batches_and_retry(live, monkeypatch): live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) - mine = mcp_server.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]) + mine = jmcp.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]) mine_id = int(mine.split("#")[1].split()[0]) - monkeypatch.setattr(mcp_server, "PROJECT", "STOCKS_DRRK") - other = mcp_server.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}]) + monkeypatch.setattr(core_mcp, "PROJECT", "STOCKS_DRRK") + other = jmcp.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}]) other_id = int(other.split("#")[1].split()[0]) - out = mcp_server.batches(limit=50) + out = jmcp.batches(limit=50) assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") - assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " not in mcp_server.batches(limit=50) - assert f"#{other_id}" in mcp_server.batch_status(other_id) # по id — без ограничения + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " not in jmcp.batches(limit=50) + assert f"#{other_id}" in jmcp.batch_status(other_id) # по id — без ограничения other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"] - out = mcp_server.command_retry(other_cmd) + out = jmcp.command_retry(other_cmd) assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out - monkeypatch.setattr(mcp_server, "PROJECT", None) # без привязки batches — все - assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " in mcp_server.batches(limit=50) + monkeypatch.setattr(core_mcp, "PROJECT", None) # без привязки batches — все + assert f"пакет #{mine_id} " in jmcp.batches(limit=50) and f"пакет #{other_id} " in jmcp.batches(limit=50) def test_project_create(live, monkeypatch): # сценарий «Проект из чата» - monkeypatch.setattr(mcp_server, "PROJECT", None) - out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) + monkeypatch.setattr(core_mcp, "PROJECT", None) + out = core_mcp.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out frag = json.loads(out[out.index("{"):]) - assert frag == mcp_server.mcp_json("STOCKS_DRRK") + assert frag == core_mcp.mcp_json("STOCKS_DRRK") assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] assert live["client"].get("/batches")["batches"][0]["author"] == "claude" - assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409") - assert mcp_server.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") + assert core_mcp.project_create("awpca", "x").startswith("ошибка хаба 409") + assert core_mcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") def test_hub_root_is_repo(): - assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() - assert mcp_server.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(mcp_server.HUB_ROOT)] + assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file() + assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)] def test_fmt_projects(): items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190}, {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}] - out = mcp_server.fmt_projects(items, "AWPCA").splitlines() + out = core_mcp.fmt_projects(items, "AWPCA").splitlines() assert out[0] == "метка | название | доска | тип доски | задач" assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" - assert mcp_server.fmt_projects([], None) == "проектов нет" - assert "(текущий)" not in mcp_server.fmt_projects(items, None) + assert core_mcp.fmt_projects([], None) == "проектов нет" + assert "(текущий)" not in core_mcp.fmt_projects(items, None) def test_run_binds_project(monkeypatch): - monkeypatch.setattr(mcp_server, "PROJECT", None) - monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None) - mcp_server.run("AWPCA") - assert mcp_server.PROJECT == "AWPCA" + monkeypatch.setattr(core_mcp, "PROJECT", None) + monkeypatch.setattr(jmcp.mcp, "run", lambda transport: None) + assert core_mcp.run("jira", "AWPCA") == 0 + assert core_mcp.PROJECT == "AWPCA" # --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------ @@ -545,29 +547,29 @@ def _ev(**kw): def test_fmt_event_status(): e = _ev() hm = _local(e["at"]).strftime("%H:%M") - assert mcp_server.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»" + assert jmcp.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»" def test_fmt_event_other_day_has_date(): e = _ev() d = _local(e["at"]) - assert mcp_server.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M ")) - assert mcp_server.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M ")) + assert jmcp.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M ")) + assert jmcp.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M ")) def test_fmt_event_dates_by_value(): e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None) - assert mcp_server.fmt_event(e, None).endswith(": срок 25.10 → —") + assert jmcp.fmt_event(e, None).endswith(": срок 25.10 → —") e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0") - assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10") + assert jmcp.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10") def test_fmt_event_kinds(): - assert mcp_server.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено") + assert jmcp.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено") long = "к" * 250 - out = mcp_server.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None) + out = jmcp.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None) assert out.endswith(": комментарий: " + "к" * 199 + "…") - out = mcp_server.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None) + out = jmcp.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None) assert out.endswith(": assignee: «—» → «ivanov»") @@ -578,7 +580,7 @@ def test_fmt_changes_grouped(): _ev(at="2026-10-22T08:15:00+00:00"), _ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description", field="description")]} - lines = mcp_server.fmt_changes(data, now).split("\n") + lines = jmcp.fmt_changes(data, now).split("\n") assert lines[0].startswith("изменения с ") assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено") assert lines[4] == "X Тема — Готово" @@ -586,7 +588,7 @@ def test_fmt_changes_grouped(): def test_fmt_changes_empty(): - out = mcp_server.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00", + out = jmcp.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00", "truncated": False, "changes": []}, datetime.now().astimezone()) assert out.split("\n")[1] == "за период изменений нет" @@ -604,51 +606,51 @@ def test_changes_today(live): # «Ч now = datetime.now(timezone.utc).replace(microsecond=0) _history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat()) _history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте - out = mcp_server.changes(kinds="status") + out = jmcp.changes(kinds="status") assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out - assert "A-1" in mcp_server.changes(hours=2) - assert "B-1" in mcp_server.changes(key="B-1") + assert "A-1" in jmcp.changes(hours=2) + assert "B-1" in jmcp.changes(key="B-1") def test_changes_hours_and_since(live): - assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since" - assert mcp_server.changes(hours=0) == "hours — число больше 0" + assert jmcp.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since" + assert jmcp.changes(hours=0) == "hours — число больше 0" def test_changes_bad_since(live): - assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422") + assert jmcp.changes(since="вчера").startswith("ошибка хаба 422") def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное - out = mcp_server.changes(since="2026-10-22T09:00", until="2026-10-22T10:00") + out = jmcp.changes(since="2026-10-22T09:00", until="2026-10-22T10:00") assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00" def test_changes_empty(live): - assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет" + assert jmcp.changes(hours=1).split("\n")[1] == "за период изменений нет" def test_changes_unbound(live, monkeypatch): - monkeypatch.setattr(mcp_server, "PROJECT", None) - assert mcp_server.changes() == mcp_server.NOT_BOUND - assert mcp_server.changes(key="A-1").split("\n")[1] == "за период изменений нет" + monkeypatch.setattr(core_mcp, "PROJECT", None) + assert jmcp.changes() == core_mcp.NOT_BOUND + assert jmcp.changes(key="A-1").split("\n")[1] == "за период изменений нет" def test_issue_shows_changes(live): # «История в карточке» live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []}) - assert "изменения (" not in mcp_server.issue("A-1") + assert "изменения (" not in jmcp.issue("A-1") _history(live, "A-1", 7) - out = mcp_server.issue("A-1") + out = jmcp.issue("A-1") assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5 def test_sync_log_history(live): - asyncio.run(mcp_server.refresh(full=True)) + asyncio.run(jmcp.refresh(full=True)) cmd = live["agent"].post("/agent/claim", {})["command"] - assert "журнал: нет" in mcp_server.sync_log() + assert "журнал: нет" in jmcp.sync_log() live["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}}) - assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log()) + assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", jmcp.sync_log()) # --- вложения (#83) ----------------------------------------------------------------- @@ -694,14 +696,14 @@ class FakeHub: @pytest.fixture def downloads(tmp_path, monkeypatch): monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) - monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") - monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05) return tmp_path / "home" / "downloads" def _use(monkeypatch, hub): - monkeypatch.setattr(mcp_server, "_client", lambda: hub) - monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None) + monkeypatch.setattr(core_mcp, "client", lambda: hub) + monkeypatch.setattr(core_mcp, "unbound_or_unknown", lambda required: None) def _issue_with_att(): @@ -713,32 +715,32 @@ def _issue_with_att(): def test_issue_attachments_block(): - text = mcp_server.fmt_issue(_issue_with_att()) + text = jmcp.fmt_issue(_issue_with_att()) assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):") lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")} assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"] assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"] assert "файлы: #31337 скрин 1.png" in text - assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[])) - assert "вложений нет" in mcp_server.fmt_issue(_detail()) + assert "вложений нет" in jmcp.fmt_issue(_detail(attachments=[])) + assert "вложений нет" in jmcp.fmt_issue(_detail()) def test_comment_attachments(): d = _issue_with_att() - text = mcp_server.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) + text = jmcp.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"]) assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)") - assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) + assert "вложения" not in jmcp.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"]) def test_safe_name_limits_255_bytes(): """M-7: имя ≤ 255 байт UTF-8, расширение сохраняется, многобайтовый символ не режется.""" for stem in ("я" * 200, "ы" * 127 + "a"): - name = mcp_server.safe_name(stem + ".xlsx", "7") + name = jmcp.safe_name(stem + ".xlsx", "7") assert len(name.encode()) <= 255 and name.endswith(".xlsx") name.encode("utf-8") assert name.startswith("я") or name.startswith("ы") - assert len(mcp_server.safe_name("ф" * 300, "7").encode()) <= 255 - assert mcp_server.safe_name("короткое.png", "7") == "короткое.png" + assert len(jmcp.safe_name("ф" * 300, "7").encode()) <= 255 + assert jmcp.safe_name("короткое.png", "7") == "короткое.png" def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): @@ -746,16 +748,16 @@ def test_attachment_long_cyrillic_name_written(downloads, monkeypatch): att = _att("31337", name, 3, state="ready") hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "ready", "command_id": 5}, polls=[att], data=b"abc") _use(monkeypatch, hub) - out = asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + out = asyncio.run(jmcp.attachment(ATT_KEY, "31337")) path = Path(out.splitlines()[0]) assert path.is_file() and path.read_bytes() == b"abc" and len(path.name.encode()) <= 255 def test_safe_name(): - assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" - assert mcp_server.safe_name(" ", "7") == "attachment-7" - assert mcp_server.safe_name("..", "7") == "attachment-7" - assert mcp_server.safe_name("a\x7f", "7") == "a_" + assert jmcp.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png" + assert jmcp.safe_name(" ", "7") == "attachment-7" + assert jmcp.safe_name("..", "7") == "attachment-7" + assert jmcp.safe_name("a\x7f", "7") == "a_" def _ready_hub(size=239821, data=b"PNG" * 3): @@ -767,7 +769,7 @@ def _ready_hub(size=239821, data=b"PNG" * 3): def test_attachment_by_id_ready(downloads, monkeypatch): hub = _ready_hub() _use(monkeypatch, hub) - out = asyncio.run(mcp_server.attachment(ATT_KEY, "#31337")) + out = asyncio.run(jmcp.attachment(ATT_KEY, "#31337")) path = downloads / "31337" / "скрин 1.png" lines = out.splitlines() assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650" @@ -781,7 +783,7 @@ def test_attachment_by_name_ambiguous(downloads, monkeypatch): hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1}, polls=[{**a20, "state": "ready"}], data=b"abc") _use(monkeypatch, hub) - out = asyncio.run(mcp_server.attachment(ATT_KEY, "скрин.png")) + out = asyncio.run(jmcp.attachment(ATT_KEY, "скрин.png")) assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out assert str(downloads / "20" / "скрин.png") in out @@ -789,7 +791,7 @@ def test_attachment_by_name_ambiguous(downloads, monkeypatch): def test_attachment_not_found(downloads, monkeypatch): hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)])) _use(monkeypatch, hub) - out = asyncio.run(mcp_server.attachment(ATT_KEY, "нет.png")) + out = asyncio.run(jmcp.attachment(ATT_KEY, "нет.png")) assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650" assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls) @@ -799,7 +801,7 @@ def test_attachment_wait_timeout(downloads, monkeypatch): fetching = {**att, "state": "fetching", "command_id": 9} hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching]) _use(monkeypatch, hub) - out = asyncio.run(mcp_server.attachment(ATT_KEY, "1", wait_seconds=1)) + out = asyncio.run(jmcp.attachment(ATT_KEY, "1", wait_seconds=1)) assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится" @@ -808,7 +810,7 @@ def test_attachment_failed(downloads, monkeypatch): failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3} hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed]) _use(monkeypatch, hub) - assert asyncio.run(mcp_server.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" + assert asyncio.run(jmcp.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404" def test_attachment_413(downloads, monkeypatch): @@ -816,7 +818,7 @@ def test_attachment_413(downloads, monkeypatch): att = _att("5", "запись.mp4", 26214400) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) _use(monkeypatch, hub) - assert asyncio.run(mcp_server.attachment(ATT_KEY, "#5")) == \ + assert asyncio.run(jmcp.attachment(ATT_KEY, "#5")) == \ "файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»" @@ -825,7 +827,7 @@ def test_attachment_409_passes_hub_text(downloads, monkeypatch): hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) _use(monkeypatch, hub) - assert "агент v9 не умеет" in asyncio.run(mcp_server.attachment(ATT_KEY, "5")) + assert "агент v9 не умеет" in asyncio.run(jmcp.attachment(ATT_KEY, "5")) def test_attachment_existing_copy_same_size(downloads, monkeypatch): @@ -836,6 +838,6 @@ def test_attachment_existing_copy_same_size(downloads, monkeypatch): path.write_bytes(b"OLD" * 3) os.utime(path, (1_000_000, 1_000_000)) os.utime(path.parent, (1_000_000, 1_000_000)) - asyncio.run(mcp_server.attachment(ATT_KEY, "31337")) + asyncio.run(jmcp.attachment(ATT_KEY, "31337")) assert path.read_bytes() == b"OLD" * 3 assert path.parent.stat().st_mtime > 1_000_000 + 100 # M-6: папка «освежена» — уборка её не заденет diff --git a/tests/test_registry.py b/tests/test_registry.py index d302046..e44b013 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -9,8 +9,8 @@ from jira_hub.core.modules import CommandHooks, Module CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" -# временно: R-1, сокращается в Task 6 -PENDING = {"cli.py", "mcp_server.py"} +# временно: R-1, список только сокращается; после Task 6 пуст +PENDING: set[str] = set() def _noop(*a, **k): -- 2.47.2 From 2d1cf84c8372b0b8ed92b38fd3c6c0068c59d490 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 22:53:26 +0400 Subject: [PATCH 16/23] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20R-9,=20R-10=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/hub-modules/design.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openspec/changes/hub-modules/design.md b/openspec/changes/hub-modules/design.md index aa86f26..ca8cc48 100644 --- a/openspec/changes/hub-modules/design.md +++ b/openspec/changes/hub-modules/design.md @@ -209,3 +209,10 @@ - 23.10.2026 — **R-8: `POST /projects` отдаёт `batch_id` первого модуля, вернувшего пакет (`null`, если никто).** Обоснование: модуль один; формат ответа прежний. Цена ошибки: при нескольких модулях с пакетами «Новый проект» остальные не видны в ответе — решит change модуля wiki. +- 23.10.2026 — **R-9: приватные помощники `core/mcp.py` (`client`, `unbound_or_unknown`, `cut`, `value`) стали публичными; + `mcp.run` возвращает код (0 после остановки сервера, 2 при неизвестном модуле), в сообщении перечислены модули с + `mcp_server`; `import` открывает своё соединение в `cli_run`.** Обоснование: их используют инструменты модуля jira из + другого пакета. Цена ошибки: нулевая; тесты, подменяющие эти имена, ссылаются на новые. +- 23.10.2026 — **R-10: тесты MCP-привязки к проекту (`test_mcp_project_option`) заменены двумя тестами из плана + (`test_mcp_default_module`, `test_mcp_unknown_module`).** Обоснование: прежний тест подменял удалённый + `mcp_server.run`. Цена ошибки: если прежний тест проверял что-то сверх, это нужно вернуть (проверяет ревью Task 6). -- 2.47.2 From 2cb454af8b23414fa044e92665363a1a3ad737ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 23:05:02 +0400 Subject: [PATCH 17/23] =?UTF-8?q?refactor(web):=20=D0=BC=D0=BE=D0=B4=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=20jira=20=D0=B8=20=D0=BE=D0=B1=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B5=D0=B9=20(#8?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/e2e/board.spec.ts | 2 +- web/scripts/golden-from-links.ts | 4 +- web/scripts/test-hub.sh | 2 +- web/src/App.tsx | 4 +- web/src/api/queries.ts | 60 +---------------- web/src/auth/LoginScreen.test.tsx | 2 +- web/src/modules/ModuleProviders.tsx | 7 ++ web/src/modules/index.ts | 19 ++++++ .../{ => modules/jira}/api/queries.test.ts | 6 +- web/src/modules/jira/api/queries.ts | 64 +++++++++++++++++++ .../jira}/board/BoardPage.test.tsx | 6 +- .../{ => modules/jira}/board/BoardPage.tsx | 6 +- .../board/__fixtures__/geometry.cases.json | 0 .../board/__fixtures__/geometry.expected.json | 0 .../board/__fixtures__/hub-basic.board.json | 0 .../__fixtures__/hub-basic.expected.json | 0 .../board/__fixtures__/hub-empty.board.json | 0 .../__fixtures__/hub-empty.expected.json | 0 .../jira}/board/__fixtures__/jira-bad.csv | 0 .../board/__fixtures__/jira-bad.expected.json | 0 .../jira}/board/__fixtures__/jira-export.csv | 0 .../__fixtures__/jira-export.expected.json | 0 .../board/__fixtures__/jira-no-links.csv | 0 .../__fixtures__/jira-no-links.expected.json | 0 .../__fixtures__/old-board.expected.json | 0 .../jira}/board/__fixtures__/old-board.json | 0 .../jira}/board/__fixtures__/scenarios.json | 0 .../jira}/board/__fixtures__/settings.json | 0 .../board/__fixtures__/storage.cases.json | 0 .../board/__fixtures__/storage.expected.json | 0 .../jira}/board/analysis/analysis.test.ts | 6 +- .../jira}/board/analysis/critical.ts | 0 .../jira}/board/analysis/facets.ts | 0 .../jira}/board/analysis/problems.ts | 0 .../jira}/board/analysis/visible.ts | 0 web/src/{ => modules/jira}/board/board.css | 0 .../board/components/BoardCanvas.test.tsx | 4 +- .../jira}/board/components/BoardCanvas.tsx | 2 +- .../jira}/board/components/BoardHeader.tsx | 2 +- .../jira}/board/components/Card.test.ts | 0 .../jira}/board/components/Card.tsx | 2 +- .../jira}/board/components/CardLate.test.tsx | 0 .../jira}/board/components/DatesBar.test.tsx | 0 .../jira}/board/components/DatesBar.tsx | 0 .../jira}/board/components/Edges.tsx | 0 .../jira}/board/components/ExportBox.tsx | 2 +- .../jira}/board/components/FilterBar.tsx | 2 +- .../jira}/board/components/Lane.tsx | 2 +- .../jira}/board/components/Legend.tsx | 2 +- .../jira}/board/components/LinkTable.tsx | 2 +- .../jira}/board/components/MultiSelect.tsx | 2 +- .../jira}/board/components/ProblemsDrawer.tsx | 2 +- web/src/{ => modules/jira}/board/constants.ts | 0 .../{ => modules/jira}/board/dicts.test.ts | 0 web/src/{ => modules/jira}/board/dicts.ts | 2 +- .../{ => modules/jira}/board/exportJson.ts | 0 .../{ => modules/jira}/board/layout/align.ts | 0 .../{ => modules/jira}/board/layout/edges.ts | 0 .../jira}/board/layout/layout.test.ts | 6 +- .../{ => modules/jira}/board/layout/layout.ts | 0 .../{ => modules/jira}/board/legendPref.ts | 0 web/src/{ => modules/jira}/board/model/csv.ts | 0 .../{ => modules/jira}/board/model/fromCsv.ts | 0 .../{ => modules/jira}/board/model/fromHub.ts | 2 +- .../jira}/board/model/fromJson.ts | 0 .../{ => modules/jira}/board/model/links.ts | 0 .../jira}/board/model/model.test.ts | 4 +- .../jira}/board/model/util.test.ts | 0 .../{ => modules/jira}/board/model/util.ts | 0 .../{ => modules/jira}/board/scrollMemory.ts | 0 .../{ => modules/jira}/board/status.test.ts | 0 web/src/{ => modules/jira}/board/status.ts | 2 +- .../{ => modules/jira}/board/storage.test.ts | 4 +- web/src/{ => modules/jira}/board/storage.ts | 0 web/src/{ => modules/jira}/board/types.ts | 0 .../{ => modules/jira}/board/useBoardPrefs.ts | 0 .../jira}/board/useBoardSource.test.tsx | 12 ++-- .../jira}/board/useBoardSource.ts | 4 +- web/src/{ => modules/jira}/feed/EventList.tsx | 2 +- .../{ => modules/jira}/feed/FeedPage.test.tsx | 12 ++-- web/src/{ => modules/jira}/feed/FeedPage.tsx | 4 +- .../{ => modules/jira}/feed/events.test.ts | 2 +- web/src/{ => modules/jira}/feed/events.ts | 2 +- web/src/{ => modules/jira}/feed/feed.css | 0 web/src/modules/jira/index.tsx | 37 +++++++++++ .../jira}/issues/IssueCard.test.tsx | 10 +-- .../{ => modules/jira}/issues/IssueCard.tsx | 11 ++-- .../jira}/issues/IssueFilters.tsx | 2 +- .../{ => modules/jira}/issues/IssueLink.tsx | 0 .../jira}/issues/IssueModal.test.tsx | 10 +-- .../{ => modules/jira}/issues/IssueModal.tsx | 4 +- .../{ => modules/jira}/issues/IssueTable.tsx | 4 +- .../jira}/issues/IssuesPage.test.tsx | 6 +- .../{ => modules/jira}/issues/IssuesPage.tsx | 7 +- .../{ => modules/jira}/issues/NoteForm.tsx | 9 +-- web/src/{ => modules/jira}/issues/Notes.tsx | 4 +- .../issues/attachments/AttachmentChips.tsx | 4 +- .../issues/attachments/AttachmentList.tsx | 4 +- .../jira}/issues/attachments/attachments.css | 0 .../jira}/issues/attachments/format.test.ts | 0 .../jira}/issues/attachments/format.ts | 0 .../useAttachmentDownload.test.tsx | 8 +-- .../attachments/useAttachmentDownload.ts | 6 +- .../{ => modules/jira}/issues/card.test.ts | 0 web/src/{ => modules/jira}/issues/card.ts | 4 +- .../{ => modules/jira}/issues/closeAbort.ts | 0 .../{ => modules/jira}/issues/commentsPref.ts | 0 web/src/{ => modules/jira}/issues/issues.css | 0 .../{ => modules/jira}/issues/modal.test.ts | 0 web/src/{ => modules/jira}/issues/modal.ts | 0 web/src/{ => modules/jira}/issues/options.ts | 2 +- web/src/modules/jira/issues/overlay.tsx | 48 ++++++++++++++ .../issues/wiki/WikiText.fallback.test.tsx | 0 .../jira}/issues/wiki/WikiText.test.tsx | 2 +- .../jira}/issues/wiki/WikiText.tsx | 2 +- .../jira}/issues/wiki/inline.test.ts | 0 .../{ => modules/jira}/issues/wiki/inline.ts | 0 .../jira}/issues/wiki/parse.test.ts | 0 .../{ => modules/jira}/issues/wiki/parse.ts | 0 .../{ => modules/jira}/issues/wiki/wiki.css | 0 .../jira/refresh}/DataStatus.tsx | 2 +- .../jira/refresh}/RefreshProvider.tsx | 4 +- .../modules/jira/refresh/SidebarStatus.tsx | 15 +++++ .../jira/refresh}/refresh.test.tsx | 8 +-- .../jira/refresh}/refresh.ts | 2 +- .../jira/refresh}/refreshStatus.test.ts | 0 .../jira/refresh}/refreshStatus.ts | 0 web/src/modules/modules.test.tsx | 20 ++++++ web/src/modules/types.ts | 19 ++++++ web/src/projects/NewProjectDialog.tsx | 1 + web/src/projects/projects.test.tsx | 4 +- web/src/shell/Layout.tsx | 63 +++++------------- web/src/shell/Sidebar.test.tsx | 4 +- web/src/shell/Sidebar.tsx | 22 ++----- web/src/shell/pages.tsx | 17 ++--- web/src/styles/tokens.test.ts | 2 +- web/src/test/batchHub.ts | 2 +- web/src/test/boardScenarios.ts | 2 +- web/src/test/render.tsx | 2 +- web/src/test/setup.ts | 2 +- 140 files changed, 389 insertions(+), 256 deletions(-) create mode 100644 web/src/modules/ModuleProviders.tsx create mode 100644 web/src/modules/index.ts rename web/src/{ => modules/jira}/api/queries.test.ts (93%) create mode 100644 web/src/modules/jira/api/queries.ts rename web/src/{ => modules/jira}/board/BoardPage.test.tsx (98%) rename web/src/{ => modules/jira}/board/BoardPage.tsx (97%) rename web/src/{ => modules/jira}/board/__fixtures__/geometry.cases.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/geometry.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/hub-basic.board.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/hub-basic.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/hub-empty.board.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/hub-empty.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-bad.csv (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-bad.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-export.csv (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-export.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-no-links.csv (100%) rename web/src/{ => modules/jira}/board/__fixtures__/jira-no-links.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/old-board.expected.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/old-board.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/scenarios.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/settings.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/storage.cases.json (100%) rename web/src/{ => modules/jira}/board/__fixtures__/storage.expected.json (100%) rename web/src/{ => modules/jira}/board/analysis/analysis.test.ts (95%) rename web/src/{ => modules/jira}/board/analysis/critical.ts (100%) rename web/src/{ => modules/jira}/board/analysis/facets.ts (100%) rename web/src/{ => modules/jira}/board/analysis/problems.ts (100%) rename web/src/{ => modules/jira}/board/analysis/visible.ts (100%) rename web/src/{ => modules/jira}/board/board.css (100%) rename web/src/{ => modules/jira}/board/components/BoardCanvas.test.tsx (98%) rename web/src/{ => modules/jira}/board/components/BoardCanvas.tsx (99%) rename web/src/{ => modules/jira}/board/components/BoardHeader.tsx (99%) rename web/src/{ => modules/jira}/board/components/Card.test.ts (100%) rename web/src/{ => modules/jira}/board/components/Card.tsx (98%) rename web/src/{ => modules/jira}/board/components/CardLate.test.tsx (100%) rename web/src/{ => modules/jira}/board/components/DatesBar.test.tsx (100%) rename web/src/{ => modules/jira}/board/components/DatesBar.tsx (100%) rename web/src/{ => modules/jira}/board/components/Edges.tsx (100%) rename web/src/{ => modules/jira}/board/components/ExportBox.tsx (95%) rename web/src/{ => modules/jira}/board/components/FilterBar.tsx (98%) rename web/src/{ => modules/jira}/board/components/Lane.tsx (97%) rename web/src/{ => modules/jira}/board/components/Legend.tsx (96%) rename web/src/{ => modules/jira}/board/components/LinkTable.tsx (92%) rename web/src/{ => modules/jira}/board/components/MultiSelect.tsx (98%) rename web/src/{ => modules/jira}/board/components/ProblemsDrawer.tsx (96%) rename web/src/{ => modules/jira}/board/constants.ts (100%) rename web/src/{ => modules/jira}/board/dicts.test.ts (100%) rename web/src/{ => modules/jira}/board/dicts.ts (94%) rename web/src/{ => modules/jira}/board/exportJson.ts (100%) rename web/src/{ => modules/jira}/board/layout/align.ts (100%) rename web/src/{ => modules/jira}/board/layout/edges.ts (100%) rename web/src/{ => modules/jira}/board/layout/layout.test.ts (96%) rename web/src/{ => modules/jira}/board/layout/layout.ts (100%) rename web/src/{ => modules/jira}/board/legendPref.ts (100%) rename web/src/{ => modules/jira}/board/model/csv.ts (100%) rename web/src/{ => modules/jira}/board/model/fromCsv.ts (100%) rename web/src/{ => modules/jira}/board/model/fromHub.ts (97%) rename web/src/{ => modules/jira}/board/model/fromJson.ts (100%) rename web/src/{ => modules/jira}/board/model/links.ts (100%) rename web/src/{ => modules/jira}/board/model/model.test.ts (96%) rename web/src/{ => modules/jira}/board/model/util.test.ts (100%) rename web/src/{ => modules/jira}/board/model/util.ts (100%) rename web/src/{ => modules/jira}/board/scrollMemory.ts (100%) rename web/src/{ => modules/jira}/board/status.test.ts (100%) rename web/src/{ => modules/jira}/board/status.ts (93%) rename web/src/{ => modules/jira}/board/storage.test.ts (98%) rename web/src/{ => modules/jira}/board/storage.ts (100%) rename web/src/{ => modules/jira}/board/types.ts (100%) rename web/src/{ => modules/jira}/board/useBoardPrefs.ts (100%) rename web/src/{ => modules/jira}/board/useBoardSource.test.tsx (95%) rename web/src/{ => modules/jira}/board/useBoardSource.ts (95%) rename web/src/{ => modules/jira}/feed/EventList.tsx (93%) rename web/src/{ => modules/jira}/feed/FeedPage.test.tsx (97%) rename web/src/{ => modules/jira}/feed/FeedPage.tsx (97%) rename web/src/{ => modules/jira}/feed/events.test.ts (98%) rename web/src/{ => modules/jira}/feed/events.ts (98%) rename web/src/{ => modules/jira}/feed/feed.css (100%) create mode 100644 web/src/modules/jira/index.tsx rename web/src/{ => modules/jira}/issues/IssueCard.test.tsx (99%) rename web/src/{ => modules/jira}/issues/IssueCard.tsx (97%) rename web/src/{ => modules/jira}/issues/IssueFilters.tsx (98%) rename web/src/{ => modules/jira}/issues/IssueLink.tsx (100%) rename web/src/{ => modules/jira}/issues/IssueModal.test.tsx (97%) rename web/src/{ => modules/jira}/issues/IssueModal.tsx (95%) rename web/src/{ => modules/jira}/issues/IssueTable.tsx (94%) rename web/src/{ => modules/jira}/issues/IssuesPage.test.tsx (99%) rename web/src/{ => modules/jira}/issues/IssuesPage.tsx (92%) rename web/src/{ => modules/jira}/issues/NoteForm.tsx (94%) rename web/src/{ => modules/jira}/issues/Notes.tsx (91%) rename web/src/{ => modules/jira}/issues/attachments/AttachmentChips.tsx (90%) rename web/src/{ => modules/jira}/issues/attachments/AttachmentList.tsx (96%) rename web/src/{ => modules/jira}/issues/attachments/attachments.css (100%) rename web/src/{ => modules/jira}/issues/attachments/format.test.ts (100%) rename web/src/{ => modules/jira}/issues/attachments/format.ts (100%) rename web/src/{ => modules/jira}/issues/attachments/useAttachmentDownload.test.tsx (96%) rename web/src/{ => modules/jira}/issues/attachments/useAttachmentDownload.ts (95%) rename web/src/{ => modules/jira}/issues/card.test.ts (100%) rename web/src/{ => modules/jira}/issues/card.ts (95%) rename web/src/{ => modules/jira}/issues/closeAbort.ts (100%) rename web/src/{ => modules/jira}/issues/commentsPref.ts (100%) rename web/src/{ => modules/jira}/issues/issues.css (100%) rename web/src/{ => modules/jira}/issues/modal.test.ts (100%) rename web/src/{ => modules/jira}/issues/modal.ts (100%) rename web/src/{ => modules/jira}/issues/options.ts (96%) create mode 100644 web/src/modules/jira/issues/overlay.tsx rename web/src/{ => modules/jira}/issues/wiki/WikiText.fallback.test.tsx (100%) rename web/src/{ => modules/jira}/issues/wiki/WikiText.test.tsx (98%) rename web/src/{ => modules/jira}/issues/wiki/WikiText.tsx (98%) rename web/src/{ => modules/jira}/issues/wiki/inline.test.ts (100%) rename web/src/{ => modules/jira}/issues/wiki/inline.ts (100%) rename web/src/{ => modules/jira}/issues/wiki/parse.test.ts (100%) rename web/src/{ => modules/jira}/issues/wiki/parse.ts (100%) rename web/src/{ => modules/jira}/issues/wiki/wiki.css (100%) rename web/src/{shell => modules/jira/refresh}/DataStatus.tsx (97%) rename web/src/{shell => modules/jira/refresh}/RefreshProvider.tsx (98%) create mode 100644 web/src/modules/jira/refresh/SidebarStatus.tsx rename web/src/{shell => modules/jira/refresh}/refresh.test.tsx (98%) rename web/src/{shell => modules/jira/refresh}/refresh.ts (94%) rename web/src/{shell => modules/jira/refresh}/refreshStatus.test.ts (100%) rename web/src/{shell => modules/jira/refresh}/refreshStatus.ts (100%) create mode 100644 web/src/modules/modules.test.tsx create mode 100644 web/src/modules/types.ts diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index ae2d6b2..c35a536 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { CW } from "../src/board/constants.ts"; +import { CW } from "../src/modules/jira/board/constants.ts"; import { agentServeRefresh, login } from "./hub.ts"; test.beforeEach(async ({ page }) => { await login(page); }); diff --git a/web/scripts/golden-from-links.ts b/web/scripts/golden-from-links.ts index 6d64baa..1af3caf 100644 --- a/web/scripts/golden-from-links.ts +++ b/web/scripts/golden-from-links.ts @@ -1,5 +1,5 @@ // разовый генератор golden-эталонов доски: исполняет