diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 988e97a..4292119 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,40 +1,42 @@ # Архитектура -Хаб моста Jira: SQLite-зеркало задач AURORA/AWPCA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. +Хаб моста Jira: SQLite-зеркало задач проектов-меток Jira-проекта AURORA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. ## Модули | Модуль | Ответственность | Зависит от | |---|---|---| | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | -| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` | +| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | -| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | +| `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | +| `mirror` | зеркало задач, связей и комментариев, разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | -| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio; `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) | +| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | `web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) -и «Задачи» (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует +и «Задачи» (`/app/issues`); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` -и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`. +и `storage.ts` (ключи `localStorage` прежней страницы; у проекта AWPCA — прежние, у остальных с суффиксом `@<метка>`; набор ключей — состояние модуля, выставляется при монтировании страницы), отрисовка — `web/src/board/components`. -Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. +- Создание проекта: `POST /projects` (веб или `project_create`) → `projects.create` и пакет «Новый проект <метка>» (`refresh` по общей JQL, `batch.project = NULL`) в одной транзакции → `claim` (хаб дописывает в `refresh` доски проектов, `boards`) → агент определяет тип доски → отчёт `refresh` пишет `board_type` → `ensure_sprint` проекта получает `board_id`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. @@ -44,7 +46,7 @@ - Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). -- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел +- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects` напрямую и ходит только через `client`; из `files` берёт только предел `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. diff --git a/CLAUDE.md b/CLAUDE.md index e3be21b..0352b92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). ## Стек и ограничения - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. @@ -14,7 +14,7 @@ ## Архитектура - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. -- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. +- `db` (схема, миграции) ← `settings`, `mirror`, `commands`, `projects` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. - `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы). - Подробная карта модулей и границ — `ARCHITECTURE.md`. @@ -24,9 +24,9 @@ - `uv sync` — установка окружения. - `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`, задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. +- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). -- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены. +- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. - `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). ## Тесты diff --git a/README.md b/README.md index 5d139a9..173de8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jira-hub -Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд, +Хаб моста Jira для АРМ КА: зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.) в SQLite, служебные заметки, очередь команд, HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен. Спека: `openspec/changes/bridge-core/` (proposal, design, specs). @@ -59,9 +59,25 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect. +Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта +(метка сравнивается точно, с учётом регистра): + +```json +{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "--project", "AWPCA"]}}} +``` + +Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`, +`agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`); +`issues`, `issue`, `notes_set`, `batch_create`, `command_retry` и `refresh` +отвечают «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)». Метка, которой нет в +хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии +`issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:`), +`issue` открывает любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в +`ensure_issue` и отвергает чужую метку. + | Инструмент | Параметры | Результат | |---|---|---| -| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач | +| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | | `issue` | `key` | поля, связи, заметки, последние команды | | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | @@ -70,18 +86,25 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | `batches` | `limit` | последние пакеты | | `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | | `refresh` | `keys` или `full`, `wait` (по умолчанию `false`: только ставит пакет; `true` — ждёт до 30 мин) | id пакета или итог пакета | +| `projects` | нет | проекты хаба: метка, название, доска, тип доски, число задач; проект сессии отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | проект заведён, пакет обновления `#N`, готовый фрагмент `.mcp.json` для репозитория проекта | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | | `sync_log` | `limit` | последние загрузки состояния | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | -Автор команд из MCP: `claude`; из API без автора: `api`. +Автор команд из MCP: `claude`; из API без автора: `api`; из веб-приложения: `viewer`. + +В примере у `ensure_issue` нет `labels`: в привязанной сессии метку проекта дописывает хаб. Новый проект — `project_create`: +в ответе «проект <метка> «<название>» заведён, пакет обновления #N», затем указание положить в корень репозитория проекта +`.mcp.json` (слить с имеющимся) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта) и сам фрагмент `.mcp.json` +с командой `uv run --project <корень хаба> jira-hub mcp --project <метка>`. «Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится. ```json -[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}, +[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана"}}, {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] ``` @@ -150,18 +173,19 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | -| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | +| `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка `refresh` отказана) | +| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком | -| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон | +| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/board` | | `PUT /issues/{key}/note` | клиент | заметки (частично) | -| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд | +| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта | | `POST /batches/{id}/cancel` | клиент | отмена пакета | -| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет) / отмена (`force` для взятой) | -| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` | +| `POST /commands/{id}/retry`, `POST /commands/{id}/cancel` | клиент | повтор (новый пакет; `project` в теле — отказ, если команда не из этого проекта) / отмена (`force` для взятой) | +| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») | | `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок | -| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET) | +| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | ```bash TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') @@ -170,7 +194,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint= ## Веб-приложение -Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и +Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board?project=<метка>`, задачи `/app/issues?project=<метка>`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и `/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: @@ -181,6 +205,24 @@ make web launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub ``` +Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно +(метка, название, необязательно номер доски Jira): после создания приложение переходит в новый проект и показывает ход +пакета обновления. Текущий проект — параметр `project` в адресе; без него приложение подставляет последний выбранный +(он помнится в браузере) или первый из списка; метка, которой нет в хабе, — «проект <метка> не найден». Раскладка и +флаги доски хранятся отдельно для каждого проекта (у AWPCA — прежние ключи `localStorage`, у остальных с суффиксом `@<метка>`). + +Выкатка проектов (порядок обязателен): агент v12 опубликован и `agent_status` показывает v12 → слияние хаба, `make web`, +перезапуск хаба (`launchctl kickstart`, только владелец; миграция схемы v4 заводит проект AWPCA «АРМ КА») → +`min_agent_version = 12` → в репозиторий `awp-ca` положить `.mcp.json` с `--project AWPCA` и переподключить MCP. Хаб +новее агента v11 не выкатывать до первого шага: v11 не знает доски проекта в параметрах команд. Доску проекта +исправляет только владелец, прямо в базе хаба: `UPDATE project SET board_id = <номер>, board_type = NULL WHERE label = '<метка>'` +(тип доски определит следующий `refresh`). + +После перезапуска хаба: нажать «Обновить из Jira», затем `projects` в MCP должен показать у AWPCA тип доски `scrum` +(до этого `ensure_sprint` AWPCA отвечает 422 «тип доски ещё не известен»). Между `git pull` и перезапуском хаба не +открывать новые сессии MCP: новый MCP/CLI мигрирует базу под работающим старым хабом (миграция v4 ему не вредит, но +лучше без неё). + Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Шрифты (IBM Plex) входят в сборку: приложение работает без доступа в интернет. Раскладка доски и вход сохраняются: приложение читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб @@ -206,9 +248,9 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | `ensure_comment` | `key`, `marker`, `body` | | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | -| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается | | `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | -| `refresh` | `keys` или `jql` | +| `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть `issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из diff --git a/docs/superpowers/plans/2026-10-19-projects-hub.md b/docs/superpowers/plans/2026-10-19-projects-hub.md new file mode 100644 index 0000000..5001fe4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-projects-hub.md @@ -0,0 +1,1047 @@ +# Несколько проектов-меток в мосте (хаб) — Implementation Plan + +> **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 + своя доска) — сущность хаба: базовая JQL собирается из проектов, API, MCP и веб-приложение +работают в разрезе одного проекта, AWPCA после выкатки работает как раньше. + +**Architecture:** новый модуль `projects` (таблица `project`, схема v4, миграция v3→v4, вычисляемая базовая JQL, доски) +стоит рядом с `settings` и ниже `queue`/`api`. Разрез «свои + соседи + эпики своих» — одна SQL-выборка в `mirror` +для `GET /board` и `GET /issues`. Проверки пакета проекта и выбор доски для `ensure_sprint` — в `queue` при постановке; +`boards` для `refresh` — в `queue.deliver_params` при выдаче. MCP привязывается к проекту флагом `--project` и ходит +только через `client`. Веб-приложение держит текущий проект в адресе (`?project=`) и `localStorage`, раскладка доски +делится по проектам суффиксом ключей (у AWPCA — прежние ключи). + +**Tech Stack:** Python 3.13 (stdlib http, SQLite), pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite, +TanStack Query, react-router 8, Vitest, Testing Library, Playwright. + +**Spec:** `openspec/changes/projects/` — `proposal.md`, `design.md` (решения 1–15, «Decisions made without discussion» +1–21), `specs/{data-model,migration,hub-api,commands,mcp,web-app,agent}/spec.md`. Спека `agent` — только контракт для +хабовой стороны (агент v12 — отдельный план в `jira-agent`). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes`, ветка `feat/61-projects` +(aep/jira-hub#61). Все команды — из корня worktree; основной checkout `jira-hub` не трогать (guard-main). + +## Global Constraints + +- Схема базы — версия 4: таблица `project(label TEXT PK, name TEXT NOT NULL, board_id INTEGER, board_type TEXT, created_at TEXT NOT NULL)`, колонка `batch.project TEXT` (NULL — пакет без проекта). +- Метка: непустая, ≤ 100 символов, без пробельных символов, `,`, `"`, `'`, `(`, `)`; регистр хранится как введён; две метки, равные без учёта регистра (`str.casefold`), не существуют. Название — непустое после `strip()`, ≤ 100. `board_id` — `int > 0` (не `bool`) или `None`. `board_type` ∈ {`scrum`, `kanban`, `None`}. +- Базовая JQL: `project = AND labels in (<метки по created_at, затем label, через ", ">)`; без проектов — `None`. Из `setting.base_jql` новый код не читает нигде. +- `settings.DEFAULTS` без `base_jql` и `board_id`; `AGENT_KEYS` без `base_jql`, с `board_id` (запасная доска агента). Миграция v4 ключи `base_jql`/`board_id` в `setting` **не удаляет**. +- Создание проекта `settings_version` не поднимает. +- Принадлежность задачи проекту и разрез — точное совпадение метки (`json_each`); «метка другого проекта» и «P в `labels_remove`» — без учёта регистра; в `ensure_issue` пакета проекта метка, равная P без учёта регистра, заменяется на P. +- Соседи на доске — обычные карточки, как сейчас (отдельной отрисовки нет); `hasSprint` — по своим задачам снимка (`neighbor = false`). +- `notes_set` в привязанной сессии MCP — свои задачи и временные ключи `new:`. +- Автор пакетов: MCP — `claude`, веб-приложение — `viewer`, API без автора — `api`, автообновление — `hub`. +- MCP: `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`, ходит только через `client` (границы ARCHITECTURE.md). +- Веб-приложение: у проекта `AWPCA` ключи `localStorage` доски — ровно нынешние (`graph-<имя>:board`, `graph-<имя>`), у остальных — те же с суффиксом `@<метка>`. Golden-эталоны `web/src/board/__fixtures__/*.expected.json` не перегенерировать (см. Task 9 про `hub-empty`). +- Новых зависимостей (Python и npm) нет; `dangerouslySetInnerHTML` не появляется. +- Язык комментариев, сообщений об ошибках, подписей и коммитов — русский, идентификаторы — английские. Каждый коммит — с `(#61)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта); в задачах, где менялся фронт, — ещё `cd web && npm run e2e`. +- Любое решение исполнителя, не записанное в спеке или в этом плане, дописывается в `openspec/changes/projects/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). Находки ревью задачи n — маркеры `T.`. + +## Review Focus + +1. Живая база v3 с `base_jql` в неожиданной форме (`labels = "AWPCA"` в кавычках, хвост `ORDER BY key`, нестандартная JQL) — хаб стартует, миграция не падает: кавычки снимаются, неразобранная JQL даёт «проектов нет» и предупреждение в лог (тесты в Task 1). +2. Агент v11 (или v12 без запроса типов) присылает результат `refresh` без ключа `boards`, ключи `boards` — строки (`"5120"`), значение `null` или мусорный ключ — типы досок не меняются, отчёт принимается (тесты в Task 6). +3. Прямой адрес карточки `/app/issues/AURORA-9001` без `project`: выбор проекта не переписывает адрес и не закрывает окно; список под окном — по запомненному проекту (тест в Task 8). +4. MCP с `--project`, хаб выключен — ответ «хаб не запущен: jira-hub serve», а не «проект … не заведён» (тест в Task 7). +5. Повтор (`retry`) старой команды: `ensure_sprint`, поставленной до миграции без `board_id` и без проекта пакета, получает доску заново; повтор команды проектного пакета без `project` наследует проект и его проверки (тесты в Task 5 и Task 6). + +--- + +### Task 1: Модуль `projects`, схема v4 и миграция v3 → v4 + +**Модель исполнителя:** sonnet (интеграция: схема, цикл миграций в `db`, новый модуль; алгоритм разбора дан в плане). + +**Files:** +- Create: `src/jira_hub/projects.py` +- Modify: `src/jira_hub/db.py` (`SCHEMA`: таблица `project`, колонка `batch.project`; `SCHEMA_VERSION = 4`; `MIGRATIONS[3]`; цикл `migrate` выполняет и вызываемые шаги) +- Modify: `tests/conftest.py` (фикстура `add_project`) +- Test: `tests/test_projects.py` (новый), `tests/test_db.py` + +**Interfaces:** +- Consumes: `settings.get(conn, key)`, `db.now()`, `db.transaction(conn)`. +- Produces (`src/jira_hub/projects.py`): + - `LEGACY_LABEL = "AWPCA"`, `LEGACY_NAME = "АРМ КА"`, `MAX_LEN = 100`, `BOARD_TYPES = ("scrum", "kanban")`. + - `class ProjectError(ValueError)` с атрибутом `field: str` (`"label"`, `"name"`, `"board_id"`), `str(e)` = `": <причина>"`; `class ProjectExists(ProjectError)` (`field = "label"`, текст «проект с такой меткой уже есть: <метка>»). + - `validate(label, name, board_id) -> tuple[str, str, int | None]` — проверка по Global Constraints, возвращает `(label, name.strip(), board_id)`. + - `create(conn, label: str, name: str, board_id: int | None = None) -> dict` — проверка, уникальность без учёта регистра, вставка с `board_type = NULL`, `created_at = db.now()`; внутри `db.transaction` (вложенный вызов — savepoint); возвращает `get(conn, label)`. + - `get(conn, label: str) -> dict | None` — точное совпадение; поля `label, name, board_id, board_type, created_at`. + - `list_projects(conn) -> list[dict]` — те же поля + `issues_count` (задачи зеркала с меткой, точное совпадение через `json_each`), порядок `created_at, label`. + - `labels(conn) -> list[str]` — в том же порядке. + - `base_jql(conn) -> str | None` — `project_key` из `setting`, при отсутствии — `"AURORA"`. + - `boards(conn) -> list[dict]` — `[{"id": int, "type": str | None}]`, доски проектов без повторов (порядок первого проекта), `type` — первый известный тип среди проектов с этой доской; проекты без доски не входят. + - `record_board_types(conn, boards) -> int` — для пар `{"": "scrum" | "kanban"}` обновляет `board_type` всех проектов с `board_id = int(id)`; не-словарь, нечисловой ключ, значение не из `BOARD_TYPES` (в том числе `None`) — пропуск; возвращает число обновлённых проектов. + - `parse_legacy_labels(jql) -> list[str] | None`, `migrate_v3(conn) -> None` (алгоритм — Step 3). +- Produces (`tests/conftest.py`): фикстура `add_project` — функция `add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict` (через `projects.create`; `name=None` → `LEGACY_NAME` для AWPCA, иначе метка; `board_type` пишется `record_board_types`, если доска и тип заданы). + +- [ ] **Step 1: Write the failing tests** + +`tests/test_projects.py` (фикстура `conn` модуля: `open_db()` + `settings.ensure_defaults`): +```python +@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None]) +def test_bad_label(conn, label): + with pytest.raises(projects.ProjectError) as e: + projects.create(conn, label, "Название") + assert e.value.field == "label" + assert projects.list_projects(conn) == [] + +@pytest.mark.parametrize("name", ["", " ", "Я" * 101, None, 7]) +def test_bad_name(conn, name): ... # field == "name", проекта нет + +@pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5]) +def test_bad_board(conn, board): ... # field == "board_id" + +def test_create_and_list(conn): + a = projects.create(conn, "AWPCA", " АРМ КА ", 4216) + assert a == {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": a["created_at"]} + projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}, {"key": "A-2", "labels": ["awpca", "x"]}], []) + got = projects.list_projects(conn) + assert [(p["label"], p["board_id"], p["issues_count"]) for p in got] == [("AWPCA", 4216, 1), ("STOCKS_DRRK", None, 0)] + +def test_order_created_then_label(conn): + projects.create(conn, "ZZZ", "z"); projects.create(conn, "AAA", "a"); projects.create(conn, "MMM", "m") + conn.execute("UPDATE project SET created_at = '2026-01-01T00:00:00+00:00' WHERE label IN ('AAA', 'MMM')") + conn.execute("UPDATE project SET created_at = '2026-01-02T00:00:00+00:00' WHERE label = 'ZZZ'") + assert projects.labels(conn) == ["AAA", "MMM", "ZZZ"] + +def test_same_label_other_case(conn): + projects.create(conn, "AWPCA", "АРМ КА") + with pytest.raises(projects.ProjectExists) as e: + projects.create(conn, "awpca", "…") + assert "уже есть" in str(e.value) and len(projects.list_projects(conn)) == 1 + +def test_base_jql(conn): + assert 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)" + settings.set_(conn, "project_key", "AUR") + assert projects.base_jql(conn).startswith("project = AUR AND") + +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}] + 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 projects.get(conn, "B")["board_type"] is None + +@pytest.mark.parametrize("jql, labels", [ + ("project = AURORA AND labels = AWPCA", ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, STOCKS_DRRK)", ["AWPCA", "STOCKS_DRRK"]), + ("project = AURORA AND labels IN (A,B)", ["A", "B"]), + ('project = AURORA AND labels = "AWPCA"', ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, awpca)", ["AWPCA"]), + ("project = AURORA", None), + ("project = AURORA AND labels = AWPCA ORDER BY key", None), + ("project = AURORA AND labels in (A B)", None), + ("", None), (None, None), (5, None), +]) +def test_parse_legacy_labels(jql, labels): + assert projects.parse_legacy_labels(jql) == labels +``` + +`tests/test_db.py`: +- `test_connect_creates_schema`: `"project"` в таблицах, `"project"` в колонках `batch`, `schema_version == "4"`. +- `_v1_database` и `test_migrates_v2_with_backup` дополнительно делают `ALTER TABLE batch DROP COLUMN project` и `DROP TABLE project` перед записью старой версии; ожидаемая версия после миграции — `"4"`. +- Новый помощник `_v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=4216)`: `db.connect` + `settings.ensure_defaults`; записывает `base_jql`/`board_id` в `setting` напрямую (`INSERT OR REPLACE`, значения через `json.dumps`); пакет с `refresh {jql: base_jql}` в `done` + строка `sync_run(kind='jql', query=base_jql, finished_at, command_id)`; пакет с `refresh {jql: base_jql}` в `queued` и ещё один в `claimed`; `refresh {jql: "key = A-1"}` в `queued`; задача, заметка; затем `DROP COLUMN project`, `DROP TABLE project`, `schema_version = 3`. + +```python +def test_migrates_v3_creates_awpca(tmp_path): + path = tmp_path / "hub.sqlite" + _v3_database(path) + conn = db.connect(path) + new = "project = AURORA AND labels in (AWPCA)" + assert projects.list_projects(conn)[0] | {"created_at": None, "issues_count": None} == { + "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте + assert settings.get(conn, "board_id") == 4216 + assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new + jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")} + assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем + assert jqls["claimed"] == new + queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] + assert sorted(queued) == sorted([new, "key = A-1"]) + assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 + conn.close() + +def test_migrates_v3_two_labels_board_to_nobody(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels in (AWPCA, STOCKS_DRRK)") + conn = db.connect(tmp_path / "hub.sqlite") + got = {p["label"]: (p["name"], p["board_id"]) for p in projects.list_projects(conn)} + assert got == {"AWPCA": ("АРМ КА", None), "STOCKS_DRRK": ("STOCKS_DRRK", None)} + assert "4216" in caplog.text + conn.close() + +def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels = AWPCA ORDER BY key") + conn = db.connect(tmp_path / "hub.sqlite") + assert projects.list_projects(conn) == [] + assert "ORDER BY key" in caplog.text + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + conn.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_projects.py tests/test_db.py -v` +Expected: FAIL (`ModuleNotFoundError: jira_hub.projects`, версия схемы `3`). + +- [ ] **Step 3: Implement `src/jira_hub/projects.py` and the schema** + +- `db.SCHEMA`: `CREATE TABLE IF NOT EXISTS project (...)` по Global Constraints; в `CREATE TABLE batch` — `project TEXT` последней колонкой. Индекс по `batch.project` не заводить (на базе v3 `SCHEMA` выполняется до `ALTER`). +- `db.MIGRATIONS[3] = ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects]`, где `_migrate_projects(conn)` делает `from . import projects` внутри функции (цикл импорта `projects → db`) и вызывает `projects.migrate_v3(conn)`; цикл в `migrate`: `step(conn) if callable(step) else conn.execute(step)`. +- Разбор и миграция (алгоритм не задаётся сигнатурой): + +```python +_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P\S+)|in\s*\((?P[^()]*)\))\s*$", re.IGNORECASE) + +def parse_legacy_labels(jql) -> list[str] | None: + m = _LEGACY.search(jql) if isinstance(jql, str) else None + if not m: + return None + out = [] + for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): + label = raw.strip().strip('"') + if _label_error(label): # та же проверка, что в validate + return None + if label.casefold() not in {x.casefold() for x in out}: + out.append(label) + return out or None + +def migrate_v3(conn) -> None: + old = settings.get(conn, "base_jql") + labels = parse_legacy_labels(old) + if labels 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(labels) > 1: + log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(labels)) + board = None + for label in labels: + 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)) +``` + +- Остальные функции — по **Interfaces**; уникальность меток — сравнение `casefold()` в Python по `labels(conn)` (SQLite `lower()` не знает кириллицу). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный (остальной код ещё читает `setting.base_jql` по умолчанию — это меняет Task 2). + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/projects.py src/jira_hub/db.py tests/conftest.py tests/test_projects.py tests/test_db.py +git commit -m "Хаб: таблица project, схема v4 и миграция base_jql в проекты (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Базовая JQL из проектов везде; настройки и `init` + +**Модель исполнителя:** sonnet (интеграция: замена `base_jql` в `settings`, `queue`, `api`, `cli` и правка существующих тестов). + +**Files:** +- Modify: `src/jira_hub/settings.py` (`DEFAULTS`, `AGENT_KEYS`, `validate`, `init_from_config`) +- Modify: `src/jira_hub/projects.py` (`ensure_legacy`) +- Modify: `src/jira_hub/queue.py` (`is_full_refresh`, `maybe_auto_refresh`) +- Modify: `src/jira_hub/api.py` (`h_refresh`, `h_board`, `h_settings_get`) +- Modify: `src/jira_hub/cli.py` (`init` заводит AWPCA) +- Test: `tests/test_settings.py`, `tests/test_cli.py`, `tests/test_queue.py`, `tests/test_api.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `projects.base_jql`, `projects.create`, `projects.get`, `projects.LEGACY_*` (Task 1); фикстура `add_project`. +- Produces: + - `projects.ensure_legacy(conn, board_id: int | None) -> bool` — заводит `AWPCA` «АРМ КА» с доской, если проекта с меткой `awpca` (без учёта регистра) нет; `True` — заведён. Неверный `board_id` из конфига → проект без доски. + - `settings.validate("base_jql", …)` → `ValueError("базовая JQL собирается из проектов (GET /projects)")`; `settings.validate("board_id", …)` → `ValueError("доска Jira — свойство проекта (POST /projects)")`. + - `GET /settings` — все настройки + `base_jql` = `projects.base_jql(conn)` (перекрывает оставшийся ключ v3). + - `POST /refresh {full: true}` без проектов → 422 `bad_refresh` «нет проектов». + +- [ ] **Step 1: Write the failing tests** + +- `tests/test_settings.py`: `test_defaults_without_jql_and_board` — `"base_jql"`, `"board_id"` нет в `DEFAULTS` и в `get_all` новой базы; `"base_jql" not in AGENT_KEYS`, `"board_id" in AGENT_KEYS`, `for_agent(conn)["board_id"] is None`. `test_jql_and_board_not_writable` — `validate("base_jql", "project = AURORA")` и `validate("board_id", 1)` → `ValueError` с «собирается из проектов» / «свойство проекта». `test_init_from_config` — вернувшийся список ключей и таблица `setting` без `base_jql` и `board_id` (прежняя проверка `board_id == 4216` уходит в `test_cli`). +- `tests/test_cli.py` (`test_init_import_tokens`): после `init --config FIXTURE` — `projects.list_projects(conn)` = один `AWPCA` «АРМ КА», `board_id == 4216`; `settings.get(conn, "board_id") is None`; `init --config … --force` — проект один, доска та же. +- `tests/test_queue.py`: +```python +def test_auto_refresh_needs_projects(conn): + queue.claim(conn, agent_version=1, settings_version=1) + assert queue.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) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} +``` + `test_auto_refresh_conditions`, `test_empty_full_refresh_keeps_neighbors`, `test_redelivered_full_refresh_marks_neighbors_once` заводят `add_project(conn)` и берут JQL из `projects.base_jql(conn)`. +- `tests/test_api.py`: `test_settings_put_rejects_jql_and_board` — `PUT /settings {base_jql: …}` → 422, `detail` содержит «базовая JQL собирается из проектов», `settings_version` прежний; то же для `{board_id: 12}` (прежний тест, писавший `board_id: 12`, переписать без него). `test_full_refresh_without_projects` — 422, `detail` «нет проектов», пакетов нет (сценарий «Полный прогон без проектов»). Тест на строке ~124 (`jql == "project = AURORA AND labels = AWPCA"`) — заводит AWPCA и ждёт `"project = AURORA AND labels in (AWPCA)"`. Тест на строке ~526 (`out["base_jql"]`) — без проектов `None`, после `add_project` — вычисленная строка. Тесты `last_sync` у `GET /board` и `_run_refresh(... {"full": True} ...)` заводят проект в фикстуре. +- `tests/test_mcp.py`: фикстура `live` заводит `add_project(conn)` (нужен `refresh(full=True)`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_settings.py tests/test_cli.py tests/test_queue.py tests/test_api.py -v` +Expected: FAIL (`base_jql` ещё в `DEFAULTS`, `init` не заводит проект, `full` берёт строку из настроек). + +- [ ] **Step 3: Implement** + +- `settings`: убрать `base_jql`, `board_id` из `DEFAULTS` и из `values` в `init_from_config`; `base_jql` убрать из `AGENT_KEYS` (агент v11/v12 его не читает); ветки `validate` для двух ключей — первыми. +- `queue.is_full_refresh`: сравнение с `projects.base_jql(conn)` (`None` → `False`). `queue.maybe_auto_refresh`: `jql = projects.base_jql(conn)`; `None` → `None` до остальных проверок. +- `api.h_refresh`: `full` → `projects.base_jql(conn)`, `None` → `ApiError(422, "bad_refresh", "нет проектов")`. `api.h_board`: `mirror.board(conn, projects.base_jql(conn))`. `api.h_settings_get`: `{**settings.get_all(conn), "base_jql": projects.base_jql(conn)}`; ответ `h_settings_put` — так же. +- `cli` `init`: после `settings.init_from_config` — `projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id"))`, строка в выводе «проект AWPCA «АРМ КА», доска » при создании. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. Затем `grep -n "base_jql" src/jira_hub/*.py` — `settings.get(conn, "base_jql")` есть только в `projects.migrate_v3`. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub tests +git commit -m "Хаб: базовая JQL из проектов, настройки без base_jql/board_id, init заводит AWPCA (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Маршруты `GET /projects` и `POST /projects` + +**Модель исполнителя:** haiku (готовый код: два обработчика по Interfaces, тесты в плане). + +**Files:** +- Modify: `src/jira_hub/api.py` (`h_projects_get`, `h_projects_post`, `ROUTES`) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.create`, `projects.list_projects`, `projects.base_jql`, `ProjectError`, `ProjectExists` (Task 1); `queue.create_batch(conn, title, author, cmds, files_dir=None)`. +- Produces: + - `GET /projects` → `200 {"projects": [{label, name, board_id, board_type, created_at, issues_count}]}`. + - `POST /projects {label, name, board_id?, author?}` → `201 {"project": {...как в списке без issues_count...}, "batch_id": int}`; пакет «Новый проект <метка>», автор `author or "api"`, одна команда `refresh {jql: <базовая JQL с новой меткой>}`, `batch.project = NULL`. Ошибки: `ProjectExists` → `409 project_exists`; `ProjectError` → `422 bad_project`, `detail = str(e)` (начинается с имени поля); `author` не строка → 422. Проект и пакет — в одной `db.transaction`. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_create_project(hub): # сценарий «Новый проект» + c = hub["client"] + conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) + assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None + got = c.get("/projects")["projects"] + assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None + assert b["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} + assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» + assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + +def test_create_project_without_board(hub): ... # 201, board_id None, author пакета "api" + +@pytest.mark.parametrize("body, status, field", [ + ({"label": "AWPCA", "name": "…"}, 409, "label"), + ({"label": "awpca", "name": "…"}, 409, "label"), + ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), + ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), + ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), + ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), +]) +def test_create_project_errors(hub, body, status, field): + c = hub["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + before = len(c.get("/batches")["batches"]) + with pytest.raises(HubError) as e: + c.post("/projects", body) + assert e.value.status == status and (field is None or e.value.detail.startswith(field)) + assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before +``` +`test_auth_scopes`: `GET /projects` агентским токеном → 401. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_api.py -k project -v` +Expected: FAIL (404 на `/projects`). + +- [ ] **Step 3: Implement handlers** по **Interfaces**; маршруты `("GET", r"^/projects$", h_projects_get, "client")`, `("POST", r"^/projects$", h_projects_post, "client")`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/api.py tests/test_api.py +git commit -m "Хаб: маршруты GET/POST /projects, создание проекта ставит полный refresh (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Разрез по проекту в зеркале, `GET /board?project=`, `GET /issues?project=` + +**Модель исполнителя:** sonnet (интеграция: CTE дан в плане, встраивается в `board` и `list_issues` с фильтрами). + +**Files:** +- Modify: `src/jira_hub/mirror.py` (`SCOPE_CTE`, `board`, `list_issues`, `labels_of`) +- Modify: `src/jira_hub/api.py` (`ISSUE_FILTERS` + `project`, `h_issues`, `h_board`, `_project_param`) +- Test: `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.get` (Task 1), `projects.base_jql` (Task 2). +- Produces: + - `mirror.board(conn, base_jql, project: str | None = None) -> dict` — с `project`: `issues` — свои и соседи, `neighbor` относительно проекта; `links` — связи, у которых `source` или `target` — своя задача; `last_sync` — как без проекта. + - `mirror.list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=())` — `filters["project"]` включает разрез; фильтр `neighbor` — относительно проекта; остальные фильтры, сортировка и порции — поверх. + - `mirror.labels_of(conn, key: str) -> list[str] | None` — метки задачи зеркала, `None` — задачи нет (использует Task 5). + - `api._project_param(conn, q) -> str | None` — `q["project"]` или `None`; неизвестный проект → `ApiError(404, "not_found", "project <метка>")`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_mirror.py` (помощник `issue()` уже есть; `PROJ = "STOCKS_DRRK"`): +```python +def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта» + mirror.apply_state(conn, [ + issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"), + issue("AURORA-2", labels=["AWPCA"], epic_key=None), + issue("AURORA-3", labels=["AWPCA"], epic_key=None), + issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале + issue("AURORA-5", labels=["AWPCA"], epic_key=None), + issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)], + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"}, + {"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"}, + {"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед + {"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале + neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе + +def test_board_project_scope(conn): + _scene(conn) + snap = mirror.board(conn, BASE, project=PROJ) + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [ + ("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)] + assert {(l["source"], l["target"]) for l in snap["links"]} == { + ("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")} + assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало + +def test_list_issues_project(conn): # сценарий «Список проекта» + _scene(conn) + keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})] + assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"] + assert keys(label="kind-dev") == ["AURORA-4"] + assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False + +def test_labels_of(conn): + _scene(conn) + assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] + assert mirror.labels_of(conn, "AURORA-404") is None +``` +`tests/test_api.py`: `test_board_and_issues_by_project` — после `POST /projects` (AWPCA, STOCKS_DRRK) и `/agent/state` со сценой `GET /board?project=STOCKS_DRRK` и `GET /issues?project=STOCKS_DRRK&neighbor=1` отдают ожидаемое; `last_sync` совпадает с ответом без `project`; `GET /issues?project=NOPE` и `GET /board?project=NOPE` → 404. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mirror.py tests/test_api.py -k "project or labels_of" -v` +Expected: FAIL (`board()` не принимает `project`). + +- [ ] **Step 3: Implement** + +Выборка разреза — один CTE для `board` и `list_issues` (единственный параметр — метка, первым в списке аргументов запроса): + +```sql +WITH own(key) AS ( + SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)), +near(key) AS ( + SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own) + UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own) + UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL), +scope(key, nb) AS ( + SELECT key, 0 FROM own + UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own)) +``` + +`board`: задачи — `JOIN scope s ON s.key = i.key`, `neighbor` = `s.nb`; связи — `WHERE source_key IN own OR target_key IN own` (тот же CTE). `list_issues`: при `project` — тот же `JOIN`, фильтр `neighbor` — по `s.nb`, в результате `neighbor = bool(s.nb)`. `api.h_board` / `api.h_issues`: `project = _project_param(conn, q)` до запроса к зеркалу. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/mirror.py src/jira_hub/api.py tests/test_mirror.py tests/test_api.py +git commit -m "Хаб: разрез по проекту — свои, соседи по связям и эпики своих в /board и /issues (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Пакет проекта — проверки постановки, `batch.project`, `GET /batches?project=`, повтор + +**Модель исполнителя:** sonnet (интеграция: проверки постановки в `queue`, API и повтор). + +**Files:** +- Modify: `src/jira_hub/queue.py` (`create_batch`, `_check_project`, `list_batches`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_batches_post`, `h_batches_get`, `h_command_retry`) +- Test: `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.get`, `projects.labels` (Task 1); `mirror.labels_of` (Task 4). +- Produces: + - `queue.create_batch(conn, title, author, cmds, files_dir=None, project: str | None = None) -> dict` — порядок: `C.validate` → `_check_project` (если `project is not None`) → `_check_setup` → хвостовой `refresh` → вставка с `batch.project`. Ответ прежний (`id`, `estimate_seconds`, `count`). + - `queue._check_project(conn, cmds: list[dict], project: str) -> list[dict]` — новые словари команд (входные `params` не мутируются); `CommandError` с текстами: «команда {i}: проекта {P} нет», «команда {i}: метка другого проекта: {L}», «команда {i}: снять метку своего проекта {P} нельзя», «команда {i}: {key} не в проекте {P}», «команда {i}: связь {source} → {target} не касается проекта {P}». + - `queue.list_batches(conn, limit=20, project: str | None = None) -> list[dict]`; `batch_view` отдаёт `project`. + - `queue.retry_command(conn, command_id, author, files_dir=None, project: str | None = None) -> dict` — `project` передан и не равен `batch.project` исходной (в том числе `NULL`) → `CommandError(f"команда #{id} не из проекта {project}")`; не передан — новый пакет с проектом исходного. + - API: `POST /batches {…, project?}` (не строка → 422 `bad_batch`); `GET /batches?project=`; `POST /commands/{id}/retry {author?, project?}` (не строка → 422). + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py` (фикстуры `conn`, `add_project`; помощник `two(conn, add_project)` заводит `AWPCA` 4216 scrum и `STOCKS_DRRK` без доски и кладёт в зеркало `AURORA-1538` (`["AWPCA"]`) и `AURORA-1` (`["STOCKS_DRRK"]`)): +```python +S = "STOCKS_DRRK" +def new_issue(labels=None, temp="new:1"): + p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"} + return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}} + +def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана» + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["kind-dev"])], project=S) + view = queue.batch_view(conn, b["id"]) + assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S] + b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] + +def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"] + b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P + +@pytest.mark.parametrize("cmd, msg", [ + (new_issue(["awpca"]), "метка другого проекта: awpca"), # «Чужая метка проекта» + (fields("AURORA-1538"), "AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта» + (fields("AURORA-1", labels_remove=["stocks_drrk"]), "снять метку своего проекта"), + (fields("AURORA-1", labels_add=["AWPCA"]), "метка другого проекта: AWPCA"), + ({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"), + ({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}}, "не касается проекта"), +]) +def test_project_batch_rejected(conn, add_project, cmd, msg): + two(conn, add_project) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [cmd], project=S) + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + +def test_project_batch_accepted(conn, add_project): + two(conn, add_project) + link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}} + queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом» + queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment", + "params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой + queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S) + +def test_unknown_project_batch(conn): ... # project="NOPE" → CommandError «проекта NOPE нет» +def test_batch_without_project_unchanged(conn, add_project): ... # labels не дописаны, batch.project None, чужие задачи проходят + +def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта» + two(conn, add_project) + a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S) + queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]] + assert len(queue.list_batches(conn)) == 2 + +def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды» + two(conn, add_project) + own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA") + loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"] + with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"): + queue.retry_command(conn, cid(own), "claude", project=S) + with pytest.raises(CommandError, match="не из проекта AWPCA"): + queue.retry_command(conn, cid(loose), "claude", project="AWPCA") + r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного + assert queue.batch_view(conn, r["id"])["project"] == "AWPCA" +``` +`tests/test_api.py`: `POST /batches {project: "STOCKS_DRRK", …}` с чужой задачей → 422, `detail` содержит «не в проекте»; `project: 5` → 422; `GET /batches?project=STOCKS_DRRK`; `POST /commands/{id}/retry {project: "STOCKS_DRRK"}` для команды пакета AWPCA → 422, пакет не создан. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "project or retry" -v` +Expected: FAIL (`create_batch() got an unexpected keyword argument 'project'`). + +- [ ] **Step 3: Implement** + +- `_check_project`: `projects.get(conn, P)` — нет → ошибка; `others = {l.casefold() for l in projects.labels(conn) if l != P}`; временные ключи — `temp_key` всех `ensure_issue` пакета; «своя» задача — временный ключ пакета или `P in (mirror.labels_of(conn, key) or [])`. `ensure_issue`: чужая метка (`casefold() in others`) — ошибка; метки, равные `P` без учёта регистра, но не точно, заменяются на `P` на своём месте (повторы `P` после замены убираются, остаётся первое вхождение); `P` после этого нет среди `labels` — дописать в конец (`labels` нет — `[P]`). `ensure_fields`: `P.casefold()` среди `labels_remove` (casefold) или чужая метка в `labels_add` — ошибка; затем проверка `key`. `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment` — проверка `key`; `ensure_link` — свой хотя бы один конец; `refresh` — без проверок. +- `create_batch` пишет `project` в `INSERT INTO batch`. `list_batches` — `WHERE project = ?` при `project`. `retry_command` — по **Interfaces** (проект исходного — `SELECT project FROM batch WHERE id = ?`). +- API: `project` из тела/запроса — `str` или отсутствует. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py +git commit -m "Хаб: пакет проекта — метка проекта, чужие метки и задачи, batch.project, повтор с project (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Доски в параметрах команд — `ensure_sprint.board_id`, `refresh.boards`, типы досок + +**Модель исполнителя:** sonnet (интеграция: постановка, выдача, отчёт агента и повтор). + +**Files:** +- Modify: `src/jira_hub/queue.py` (`_assign_boards` в `_check_setup`, `deliver_params`, `claim`, `report_result`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_agent_command` — новая сигнатура `deliver_params`) +- Test: `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.list_projects`, `projects.boards`, `projects.record_board_types` (Task 1); `mirror.labels_of` (Task 4); `_check_project` уже дописал метку проекта в `ensure_issue` (Task 5). +- Produces: + - `ensure_sprint` в базе — `{key, sprint, board_id}`; `board_id` от клиента отвергает `C.validate` («лишние параметры board_id»), SPEC команд не меняется. + - `queue.deliver_params(conn, cmd, files_dir) -> dict` (новый первый параметр): `ensure_attachment` — `size`, как раньше; `refresh` — `{**params, "boards": projects.boards(conn)}` всегда, в том числе `[]`; в базе `boards` нет. + - `report_result`: для `refresh`, если `result` — словарь с ключом `boards`, — `projects.record_board_types(conn, result["boards"])` в той же транзакции; результат без ключа `boards` (агент v11 или v12 без запроса типов) типы не трогает. + - `retry_command` снимает `board_id` с параметров `ensure_sprint` до проверок. + - Тексты 422 (`CommandError`, `i` — номер команды): «команда {i}: {key} не входит ни в один проект — доску для спринта не определить», «команда {i}: {key} в проектах {A, B} с разными досками», «команда {i}: у проекта {P} нет доски Jira», «команда {i}: у проекта {P} канбан-доска, спринтов нет», «команда {i}: тип доски проекта {P} ещё не известен — дождитесь обновления из Jira». + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py`: +```python +def sprint(key, name="Спринт 2"): + return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}} + +def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216} + +@pytest.mark.parametrize("setup, key, msg", [ + (dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"), + (dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"), + (dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"), + (dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале + (dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"), +]) +def test_sprint_board_rejected(conn, add_project, setup, key, msg): + add_project(conn) # AWPCA 4216 scrum + add_project(conn, "STOCKS_DRRK", **setup) + mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]}, + {"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], []) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [sprint(key)]) + +def test_backlog_on_kanban_rejected(conn, add_project): ... # sprint "backlog", доска kanban → 422 «канбан-доска» +def test_sprint_two_projects_same_board(conn, add_project): ... # метки AWPCA и C, у обоих 4216 → board_id 4216 +def test_sprint_for_new_issue_uses_batch_labels(conn, add_project): + ... # пакет project="AWPCA": ensure_issue new:1 без labels + ensure_sprint new:1 → board_id 4216 (метка дописана Task 5) + +def test_client_board_id_rejected(conn, add_project): + add_project(conn) + with pytest.raises(CommandError, match="лишние параметры board_id"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}]) + +def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + +def test_retry_premigration_sprint_without_board(conn, add_project): + ... # команда ensure_sprint вставлена SQL-ем с params без board_id в пакет с project NULL → retry ставит board_id 4216 + +def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 + add_project(conn); add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None); add_project(conn, "C", board_id=4216) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}]) + got = queue.claim(conn) + assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + +def test_refresh_boards_empty_list(conn, add_project): + add_project(conn, board_id=None, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert queue.claim(conn)["params"]["boards"] == [] + +@pytest.mark.parametrize("result, expected", [ + ({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int + ({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет + ({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется + ({"boards": {"abc": "scrum"}}, None), + (None, None), +]) +def test_refresh_result_board_types(conn, add_project, result, expected): + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result=result) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected +``` +Существующие тесты с `ensure_sprint` (`test_temp_key_substitution_and_skip`, `test_reconcile_confirms_by_state`, `test_reconcile_does_not_overwrite_finished_command`, `test_bug_with_attachment_one_batch`) заводят `add_project(conn)` и кладут ключи задач (или `labels` у `ensure_issue`) с меткой `AWPCA`. + +`tests/test_api.py`: `GET /agent/commands/{id}` для взятого `refresh` отдаёт `boards`; полный круг «Тип доски определён» через HTTP — `POST /projects` STOCKS_DRRK с доской 5120, `claim` → в `params.boards` есть `{id: 5120, type: null}`, отчёт `done` с `result.boards = {"5120": "kanban"}` → `GET /projects` отдаёт `board_type = "kanban"`, затем `POST /batches` с `ensure_sprint` для задачи STOCKS_DRRK → 422 «канбан-доска». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "sprint or board" -v` +Expected: FAIL (`board_id` не дописывается, `boards` нет в выдаче). + +- [ ] **Step 3: Implement** + +- `_assign_boards(conn, cmds)` вызывается из `_check_setup` (после проверок `bug`/вложений): метки временного ключа — `labels` его `ensure_issue` в пакете; иначе `mirror.labels_of`; проекты задачи — `[p for p in projects.list_projects(conn) if p["label"] in labels]`; множество их `board_id` (с `None`) из одного значения, иначе — «с разными досками»; дальше проверки по порядку: нет проектов → «не входит ни в один проект», доска `None` → «нет доски», `kanban` → «канбан-доска», `None`-тип → «ещё не известен»; иначе `c["params"] = {**p, "board_id": id}` (`P` в текстах — метка первого проекта). +- `deliver_params(conn, cmd, files_dir)`; вызовы в `claim` и `api.h_agent_command` передают `conn`. +- `report_result`: запись типов — в общей транзакции отчёта, только в ветке нетерминальной команды. +- `retry_command`: `params = {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")}`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py +git commit -m "Хаб: доска проекта в ensure_sprint, boards в выдаче refresh, типы досок из отчёта агента (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: MCP — `--project`, привязка инструментов, `projects`, `project_create` + +**Модель исполнителя:** sonnet (интеграция: декоратор привязки для обычных и async-инструментов, новые инструменты, правка тестов). + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` +- Modify: `src/jira_hub/cli.py` (`mcp --project`) +- Test: `tests/test_mcp.py`, `tests/test_cli.py` + +**Interfaces:** +- Consumes: HTTP `GET/POST /projects` (Task 3), `GET /issues?project=` (Task 4), `POST /batches {project}`, `GET /batches?project=`, `POST /commands/{id}/retry {project}` (Task 5). +- Produces (`mcp_server`): + - `PROJECT: str | None = None` — привязка сессии; `run(project: str | None = None) -> None` записывает её и запускает stdio. + - `NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"`; `unknown_project(label) -> str` = `f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"`. + - `scoped(required: bool)` — декоратор под `guard` (для обычных и async-функций): `PROJECT is None` и `required` → `NOT_BOUND` без запросов к хабу; `PROJECT` задан → `GET /projects`, метки нет → `unknown_project(PROJECT)`; иначе вызов инструмента. `required=True`: `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh`; `required=False`: `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`. Без декоратора: `projects`, `project_create`. + - `HUB_ROOT = Path(__file__).resolve().parents[2]`; `mcp_json(label: str) -> dict` = `{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp", "--project", label]}}}`. + - `fmt_projects(items, current: str | None) -> str` — строка заголовка `метка | название | доска | тип доски | задач`, строки с `—` для пустой доски и «неизвестен» для `board_type = None`, у текущего — суффикс ` (текущий)`; пустой список — «проектов нет». + - Инструменты `projects() -> str`, `project_create(label: str, name: str, board_id: int | None = None) -> str`. + +- [ ] **Step 1: Write the failing tests** + +Фикстура `live`: `add_project(conn)` (AWPCA) и `monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")`; `test_tools_roundtrip` работает в привязанной сессии. +```python +def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» + monkeypatch.setattr(mcp_server, "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 + assert calls == [] + assert "AWPCA" in mcp_server.projects() + assert mcp_server.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 "AWPCA" in mcp_server.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 + +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": "т"}}]) + 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") + +def test_bound_batches_and_retry(live): ... # batches показывает только пакеты AWPCA; command_retry команды пакета без проекта → «ошибка хаба 422 … не из проекта AWPCA» + +def test_project_create(live, monkeypatch): # сценарий «Проект из чата» + monkeypatch.setattr(mcp_server, "PROJECT", None) + out = mcp_server.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["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") + +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)] + +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() + assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" + assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" + assert mcp_server.fmt_projects([], None) == "проектов нет" +``` +(`live` дополнительно отдаёт `"client"` — клиентский `HubClient`.) `test_offline_message` ставит `PROJECT = "AWPCA"`. `test_batch_create_mentions_labels` — описание `batch_create` содержит «метку проекта сессии хаб дописывает в ensure_issue сам», «метки других проектов запрещены», «board_id для ensure_sprint выставляет хаб» и не содержит «AWPCA». `test_async_tools_are_coroutines_and_registered` проходит без изменений. `tests/test_cli.py`: `cli.main(["mcp", "--project", "AWPCA"])` вызывает `mcp_server.run("AWPCA")` (подменить `run`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mcp.py tests/test_cli.py -v` +Expected: FAIL (`mcp_server` без `PROJECT`, `projects`, `project_create`). + +- [ ] **Step 3: Implement** по **Interfaces**: +- `issues` — `project=PROJECT` в `GET /issues`; `notes_set` — временный ключ `new:` (`commands.is_temp`) — без проверки; иначе сначала `GET /issues/{key}`, `PROJECT` не среди `labels` → `f"{key} не в проекте {PROJECT}"`; `batch_create` — `"project": PROJECT` в теле; `command_retry` — `{"author": AUTHOR, "project": PROJECT}`; `batches` — `project=PROJECT` (без привязки — все). +- `project_create` — `POST /projects {label, name, board_id, author: "claude"}` (`board_id=None` не отправлять); ответ: строка «проект <метка> «<название>» заведён, пакет обновления #», строка «Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: /mcp → reconnect, одобрите сервер проекта.» и `json.dumps(mcp_json(label), ensure_ascii=False, indent=2)` последним блоком. +- Описание `batch_create` — абзац про AWPCA заменить текстом из спеки mcp («метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб»). +- `cli`: `p = sub.add_parser("mcp", …); p.add_argument("--project")`; `mcp_server.run(args.project)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/mcp_server.py src/jira_hub/cli.py tests/test_mcp.py tests/test_cli.py +git commit -m "MCP: привязка сессии к проекту (--project), инструменты projects и project_create (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Веб-приложение — текущий проект, выпадающий список, «Новый проект», доска и список по проекту + +**Модель исполнителя:** sonnet (интеграция: оболочка, роутинг, контекст проекта, страницы, e2e-адреса). + +**Files:** +- Create: `web/src/projects/current.ts`, `web/src/projects/current.test.ts` +- Create: `web/src/projects/ProjectContext.ts`, `web/src/projects/ProjectPicker.tsx`, `web/src/projects/ProjectGate.tsx`, `web/src/projects/NewProjectDialog.tsx`, `web/src/projects/projects.css`, `web/src/projects/projects.test.tsx` +- Modify: `web/src/api/types.ts` (`Project`, `CreatedProject`, `Batch.project`), `web/src/api/queries.ts` (`qk`, `useBoard`, `useProjects`, `useCreateProject`, инвалидация в `useSaveNote`) +- Modify: `web/src/shell/Layout.tsx`, `web/src/shell/pages.tsx` +- Modify: `web/src/board/BoardPage.tsx`, `web/src/board/useBoardSource.ts`, `web/src/issues/IssuesPage.tsx` +- Modify: `web/scripts/seed-hub.py` (метка `AWPCA` у задач фикстуры без `neighbor`) +- Test: `web/src/App.test.tsx`, `web/src/board/BoardPage.test.tsx`, `web/src/board/useBoardSource.test.tsx`, `web/src/issues/IssuesPage.test.tsx`, `web/src/api/queries.test.ts`; e2e — правка адресов в `web/e2e/board.spec.ts`, `web/e2e/issues.spec.ts`, `web/e2e/login.spec.ts` + +**Interfaces:** +- Consumes: `GET/POST /projects` (Task 3), `GET /board?project=`, `GET /issues?project=` (Task 4). +- Produces: + - `types.ts`: `interface Project { label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; issues_count: number }`; `interface CreatedProject { project: Project; batch_id: number }`; `Batch.project?: string | null`. + - `queries.ts`: `qk.projects = ["projects"]`, `qk.board(project: string) = ["board", project]`, `qk.boardAll = ["board"]` (инвалидация всех снимков); `useBoard(project: string)` → `GET /board?project=`; `useProjects()` → `GET /projects`, данные — `Project[]` (`r.projects ?? []`); `useCreateProject()` — мутация `POST /projects {label, name, board_id?, author: "viewer"}` → `CreatedProject`, при успехе инвалидирует `qk.projects`. + - `current.ts`: `REMEMBER_KEY = "hub-project"`; `type Resolution = { kind: "ok"; project: Project; fromUrl: boolean } | { kind: "unknown"; label: string } | { kind: "none" }`; `resolveProject(urlLabel: string | null, remembered: string | null, projects: Project[]): Resolution` (адрес → запомненный, если есть в списке → первый; метка из адреса, которой нет, — `unknown`; пустой список — `none`); `rememberedProject(): string | null`, `rememberProject(label: string): void` (обёрнуты в try/catch); `withProject(search: string, label: string): string` — query-строка с `project`, прочие параметры сохраняются. + - `ProjectContext.ts`: `type ProjectState = { kind: "loading" } | { kind: "error"; error: Error } | Resolution`; `interface ProjectCtx { state: ProjectState; projects: Project[]; select(label: string): void; openNew(): void }`; `ProjectContext`, `useProjectContext()`. + - `ProjectPicker()` — `` — строка `" new"` (с пробелом, который метка + содержать не может); выбор проекта из панели ведёт на раздел `/issues`, если открыт он, иначе (в том числе на + «не найдено» и под карточкой с чужим разделом) — на `/board`.** Обоснование: инвариант метки (без пробельных + символов) исключает коллизию; брифом раздел для остальных страниц не задан. Цена ошибки: константа `NEW` и + выражение `section` в `Layout.tsx`. +34. 2026-10-19 — **тесты, которые брифом не названы, поправлены под новые сигнатуры: `auth/LoginScreen.test.tsx` + (`useBoard("AWPCA")`, `qk.board("AWPCA")`), `issues/IssueModal.test.tsx` (мок отдаёт `/projects`); + в `App.test.tsx` ожидание «/app ведёт на доску» — через `waitFor`.** Обоснование: без `/projects` оболочка не + показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с + тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты. +35. 2026-10-19 — **в тесте `storage.test.ts` выражение `{…} as JsonImport` заменено на `as unknown as JsonImport`.** Обоснование: `tsc --noEmit` (шаг `make check`) отвергает прямое приведение частичного объекта к `JsonImport` (нет `model`, `dateColumns`, `collapsedLanes`); в тесте важны только `layout`, `layoutByDate`, `laneOrder`, `filters`, и `applyJsonSettings` читает только их. Цена ошибки: тест передаёт неполный импорт; если `applyJsonSettings` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа. +36. 2026-10-19 — **`POST /batches` с неизвестным `project`: сообщение «проекта {P} нет» без префикса «команда {i}».** + Обоснование: ошибка пакетная, а не одной команды. Цена ошибки: только текст сообщения. +37. 2026-10-19 — **`GET /projects` отвечает `{"projects": [...]}`.** Обоснование: как прочие списки API + (`{"batches": …}`), ответ расширяем полями без поломки клиентов. Цена ошибки: форма ответа; клиент `client` и веб + читают ключ `projects`. +38. 2026-10-19 — **пакет «Новый проект» — с `batch.project = NULL`.** Обоснование: его `refresh` по общей JQL обновляет все + проекты, а не один; привязка к новой метке спрятала бы его из общих списков и от проверок других проектов. Цена + ошибки: пакет виден в `GET /batches` без фильтра и не виден с `?project=`. +39. 2026-10-19 — **доска для `ensure_sprint` выбирается и в пакетах без проекта (веб-приложение, старые клиенты); задача вне + всех проектов — 422, хотя раньше проходила.** Обоснование: без доски проекта агенту нечего передать, прежней общей + доски в настройках больше нет (решение 4). Цена ошибки: постановка спринта задаче без метки проекта отказывает с + «не входит ни в один проект». +40. 2026-10-19 — **`board_id` от клиента в `ensure_sprint` отвергается общей проверкой «лишние параметры board_id», без + отдельного текста.** Обоснование: параметр ставит только хаб; общая проверка уже называет допустимые параметры. Цена + ошибки: текст отказа не объясняет, что доску определяет проект. +41. 2026-10-19 — **веб: память выбранного проекта — `localStorage["hub-project"]`; ручная смена проекта — новая запись + истории, фильтры списка сбрасываются; автоподстановка проекта в адрес — `replace` и только без открытой карточки.** + Обоснование: «Назад» должен возвращать к прежнему проекту; подстановка не должна заменять прямой адрес карточки. + Цена ошибки: один ключ `localStorage` и ветка в подстановке адреса. +42. 2026-10-19 — **e2e: пакет «Новый проект» (автор `viewer`) в `projects.spec.ts` разбирается `agentServeRefresh()`.** + Обоснование: иначе он остаётся в очереди и держит «Обновить из Jira» в других файлах на общем тестовом хабе. Цена + ошибки: нестабильные e2e соседних файлов при смене порядка файлов. +43. 2026-10-19 — **миграция v3 → v4 — Python-шаг `projects.migrate_v3`, вызываемый из `db.MIGRATIONS[3]` с ленивым + импортом; кавычки вокруг метки в старой `base_jql` снимаются; JQL с хвостом (`ORDER BY` и т. п.) не разбирается — + проектов нет, предупреждение в лог.** Обоснование: чистый SQL метки из JQL не вынимает; `projects` зависит от `db`, а + не наоборот, поэтому импорт ленивый. Цена ошибки: при нестандартной `base_jql` проекты придётся завести вручную + (`POST /projects`). +44. 2026-10-19 — **`base_jql` убран из `AGENT_KEYS` (агент его не читает: JQL приходит в `params.jql` команды).** + Обоснование: ключ в настройках агента вводил бы в заблуждение и расходился с вычисляемым значением. Цена ошибки: + старый агент без ключа — проверено, он берёт JQL из команды. +45. 2026-10-19 — **метка в `?project=`, `POST /batches {project}`, `retry {project}`, `--project` сравнивается точно + (регистр значим).** Обоснование: метка хранится как введена, принадлежность задачи — точное совпадение (общие + правила); неточная привязка размыла бы разрез. Цена ошибки: `--project awpca` отвечает «проект не заведён». +46. 2026-10-19 — **веб: набор ключей доски — состояние модуля `storage.ts`, выставляется в `useBoardPrefs` при монтировании + страницы (страница пересоздаётся по `key={project}`); миграции старых ключей — только для AWPCA.** Обоснование: + функции хранилища вызываются из множества мест без параметра проекта; пересоздание страницы исключает гонку ключей. + Цена ошибки: глобальное состояние модуля; чтение ключей до монтирования страницы даст набор прежнего проекта. +47. 2026-10-19 — **тестовый хаб: `seed-hub.py` дописывает метку `AWPCA` задачам фикстуры без `neighbor`; файл фикстуры не + меняется.** Обоснование: в `hub-basic` метка есть только у двух задач из десяти, а разрез доски — по метке; golden- + эталоны привязаны к фикстуре. Цена ошибки: e2e видят задачи, которых по меткам фикстуры в проекте нет. +48. 2026-10-19 — **эталон `hub-empty.expected.json` не перегенерируется (его пишет прежняя страница, где `hasSprint` всегда + `true`): тест сравнивает с ним, подменив `hasSprint` на `false`.** Обоснование: решение 12 меняет смысл `hasSprint`; + эталон — снимок прежней страницы. Цена ошибки: эталон расходится с прежней страницей в одном поле. +49. 2026-10-19 — **`boards()` отдаёт `type = null`, если тип неизвестен хотя бы у одного проекта доски (иначе — общий + известный тип; при расхождении — первый по порядку проектов).** Обоснование: спека «board_type только по результату + refresh»; агент типы известных досок не перезапрашивает, поэтому новый проект на уже известной доске оставался + с `board_type = NULL` навсегда, и его `ensure_sprint` получал 422; `record_board_types` проставляет тип всем проектам + доски. Цена ошибки: проект на известной доске без спринтов до ручной правки базы. +50. 2026-10-19 — **в пакете проекта метки `labels_add` у `ensure_fields`, равные P без учёта регистра, заменяются на P + (повтор P убирается), как в `ensure_issue`.** Обоснование: единообразие с `ensure_issue`; иначе в Jira уходит + вариант метки в чужом регистре. Цена ошибки: лишняя метка-двойник у задачи. +51. 2026-10-19 — **`queue._assign_boards` берёт проекты из `projects.light_list` (label, board_id, board_type) без + `issues_count`.** Обоснование: подсчёт задач по всему зеркалу при каждой постановке `ensure_sprint` не нужен. + Цена ошибки: нет (поведение и порядок те же). diff --git a/openspec/changes/projects/proposal.md b/openspec/changes/projects/proposal.md new file mode 100644 index 0000000..ac997e6 --- /dev/null +++ b/openspec/changes/projects/proposal.md @@ -0,0 +1,57 @@ +## Why + +Владелец работает в одном Jira-проекте AURORA и разграничивает свои проекты метками. Сейчас мост знает один такой +проект — AWPCA (АРМ КА): базовая JQL `project = AURORA AND labels = AWPCA` и одна доска `board_id = 4216` зашиты в +настройки. Нужен второй проект — STOCKS_DRRK — со своей доской Jira (возможно, канбан без спринтов) и своим +репозиторием, откуда Claude ведёт его задачи через MCP. Дальше проектов может стать больше. + +Решения владельца 19.10.2026 (brainstorming, jira-hub#61): +- **полноценный вариант**: проект — сущность хаба, а не фильтр поверх общей JQL; +- у каждого проекта **своя доска Jira**, у неё может не быть спринтов (канбан); +- **сессия Claude в репозитории проекта видит и меняет только задачи своего проекта**; связи между проектами не нужны; +- **веб-приложение одно**, проект переключается выпадающим списком в шапке (вариант A из макетов), в его конце — + «+ Новый проект…»; +- проект заводит **человек из веб-приложения или Claude через MCP**; Claude затем сам прописывает в репозитории, с каким + проектом работает его сессия; +- **обновление из Jira — одним запросом по всем проектам**: метки всех проектов собираются в одну JQL; так и для + автообновления, и для кнопки «Обновить из Jira», и для `refresh` из MCP; +- **пока только создание проекта**: переименования и удаления нет. + +## What Changes + +- **Проекты в хабе.** Новая таблица `project` (схема v4): метка Jira (ключ, не меняется), название, номер доски Jira + (может отсутствовать), тип доски (`scrum` / `kanban`, определяет агент). Маршруты `GET /projects`, `POST /projects`. + Миграция заводит проект AWPCA «АРМ КА» с доской 4216 из нынешних настроек. +- **Базовая JQL собирается из проектов** — `project = AND labels in ("<метка>", …)`; настройки + `base_jql` и `board_id` уходят из `setting`. Создание проекта ставит полный `refresh`. +- **Разрез по проекту в API.** `GET /board?project=` и `GET /issues?project=` отдают задачи проекта и их соседей + (задачи других меток, связанные с ними, и их эпики) с признаком `neighbor` относительно проекта. +- **Пакет проекта.** `POST /batches` принимает `project`: хаб добавляет метку проекта в `ensure_issue`, запрещает чужие + метки проектов и команды над задачами вне проекта; пакет помнит свой проект (`batch.project`). +- **Спринты по доске проекта.** `ensure_sprint` получает `board_id` доски проекта задачи от хаба; на канбан-доске и без + доски — 422 при постановке. `refresh` получает от хаба список досок проектов; агент собирает спринты со всех + scrum-досок и сообщает тип досок, тип которых хабу ещё не известен. +- **MCP с привязкой к проекту.** `jira-hub mcp --project <метка>`: инструменты задач и пакетов работают в разрезе + проекта. Без привязки доступны только список и создание проектов и служебные инструменты. Новые инструменты + `projects`, `project_create` (ответ — готовый фрагмент `.mcp.json` для репозитория проекта). +- **Веб-приложение.** Выпадающий список проектов в шапке, форма «Новый проект», проект в адресе (`?project=`) и в + памяти браузера; доска и список задач — по выбранному проекту; раскладка доски хранится отдельно для каждого + проекта, раскладка AWPCA сохраняется. +- **Агент v12** (отдельный PR в jira-agent): доски из параметров команд вместо настройки `board_id`, тип доски. + +## Non-Goals + +- Переименование, удаление проекта, смена его доски — позже. +- Свои для каждого проекта словари (`kind_labels`, `link_types`, `transitions_map`, `issue_types`, `closed_statuses`) — + Jira-проект один, словари общие. +- Проекты в других Jira-проектах (кроме `project_key` = AURORA). +- Отдельное автообновление и отдельная кнопка «обновить» для одного проекта. +- Перенос задачи из проекта в проект (смена метки проекта) командами в разрезе проекта. +- Смена проекта внутри запущенной сессии MCP (только перезапуском с другим `--project`). + +## Anti-Goals + +- Задача одного проекта получила метку другого (или осталась без метки своего) из-за того, что сессия Claude не знала, + в каком она проекте. +- AWPCA после выкатки потерял задачи, заметки, раскладку доски или историю синхронизаций. +- Сессия в репозитории одного проекта по умолчанию видит и меняет задачи другого. diff --git a/openspec/changes/projects/specs/agent/spec.md b/openspec/changes/projects/specs/agent/spec.md new file mode 100644 index 0000000..a5ac2ba --- /dev/null +++ b/openspec/changes/projects/specs/agent/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Доски проектов +Агент (с v12) SHALL брать доски Jira из параметров команд, а не из настроек: `ensure_sprint` — из `board_id` команды, +`refresh` — из `boards` (`[{id, type}]`). В `refresh` для каждой доски с `type = null` агент SHALL запросить +`GET /rest/agile/1.0/board/{id}` и вернуть в результате команды `boards: {"": "scrum" | "kanban" | null}` (`null` — +доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена +спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа +`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Если доска, которую хаб передал как `scrum`, не отдала спринты, агент SHALL перепроверить её тип: стала `kanban` — доска пропускается, в результате `boards: {"": "kanban"}` (хаб записывает тип и при прежнем `scrum`); иначе `refresh` — `failed` с текстом «доска : …» (агент v12, R-36). Команда без `board_id`/`boards` +(хаб до проектов) SHALL выполняться по `settings.board_id`, как в v11. `ensure_sprint` с `board_id` канбан-доски +агент не получает (хаб отказывает при постановке); если Jira всё же ответила, что у доски нет спринтов, — `failed` с +текстом Jira. + +#### Scenario: Две доски, одна новая +- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}, {id: 5120, type: null}]}`, доска 5120 в Jira — kanban +- **THEN** агент запрашивает `GET /board/5120` один раз, спринты читает только с доски 4216; результат содержит + `boards: {"5120": "kanban"}` + +#### Scenario: Доска не найдена +- **WHEN** `refresh` с `boards: [{id: 9999, type: null}]`, Jira на `GET /board/9999` ответила 404 +- **THEN** результат `refresh` — `done` с `boards: {"9999": null}`; в логе агента — доска 9999 и код ответа + +#### Scenario: Старый хаб +- **WHEN** агент v12, хаб без проектов, `refresh {jql: …}` без `boards`, `settings.board_id = 4216` +- **THEN** спринты читаются с доски 4216, результат без `boards` + +#### Scenario: Спринт по доске из команды +- **WHEN** `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2", board_id: 4216}` +- **THEN** агент ищет «Спринт 2» среди активных и будущих спринтов доски 4216 и ставит задачу в него + +## MODIFIED Requirements + +### Requirement: Состав и настройки агента +Агент SHALL состоять из файла `agent.py` (Python ≥ 3.9, только стандартная библиотека), запускателя `run.py` (те же +ограничения) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` +или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, необязательный `auto_update` (по +умолчанию `true`). `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида +`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, +темп) агент SHALL получать от хаба при старте и при изменении `settings_version`; доски Jira — в параметрах команд +(требование «Доски проектов»). Учётные данные Jira MUST NOT отправляться в хаб и MUST NOT попадать в журнал, логи и общую +папку. Команды `agent.py`: `run` (по умолчанию), `check`, `selftest`, `prune`. + +#### Scenario: Проверка без записи +- **WHEN** `python3 agent.py check` +- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет + +#### Scenario: Проверка через папку +- **WHEN** `hub_url = "folder:/Volumes/My Shared Files/RSHB/jira-bridge"` и реле хаба работает +- **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v, проект + »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 + +#### Scenario: Самопроверка новой версии +- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны +- **THEN** печатается «jira-agent v» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется; + если хаб или Jira недоступны — код 3 diff --git a/openspec/changes/projects/specs/commands/spec.md b/openspec/changes/projects/specs/commands/spec.md new file mode 100644 index 0000000..786f9da --- /dev/null +++ b/openspec/changes/projects/specs/commands/spec.md @@ -0,0 +1,117 @@ +## ADDED Requirements + +### Requirement: Пакет проекта +Пакет, поставленный с `project = P` (см. `hub-api`), SHALL проходить при постановке, до записи, проверки проекта; +любая неудача — 422 с ключом задачи и причиной, пакет не создаётся: +- `P` нет среди проектов — 422; +- `ensure_issue`: если в `labels` нет P, хаб SHALL дописать P в конец `labels` (в базе параметры хранятся уже с ней; + без `labels` — `labels = [P]`); метка, совпадающая с P без учёта регистра, но не точно, SHALL заменяться на P; метка + другого проекта в `labels` — 422; +- `ensure_fields`: P в `labels_remove` или метка другого проекта в `labels_add` — 422; +- `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным + ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; +- `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P; +- `refresh` — без проверок проекта. +«Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту +(метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит, +метки хаб в нём не дописывает. + +#### Scenario: Метка проекта дописана +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {temp_key: new:1, summary: "Сверка остатков", labels: ["kind-dev"]}` +- **THEN** пакет создан; в параметрах команды `labels = ["kind-dev", "STOCKS_DRRK"]` + +#### Scenario: Чужая метка проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {…, labels: ["awpca"]}`, есть проект `AWPCA` +- **THEN** 422 «метка другого проекта: awpca», пакет не создан + +#### Scenario: Задача другого проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_fields {key: AURORA-1538, duedate: …}`, у AURORA-1538 метка только `AWPCA` +- **THEN** 422 «AURORA-1538 не в проекте STOCKS_DRRK» + +#### Scenario: Связь с соседом +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_link {source: AURORA-1 (метка STOCKS_DRRK), target: AURORA-1538 (метка AWPCA), …}` +- **THEN** пакет создан + +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` | +| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал | + +Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в +зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` — +доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта +нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P +ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе +не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и +`kanban` в проекты с этой доской; `null` тип не меняет. + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +#### Scenario: Спринт задачи проекта +- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}` +- **THEN** пакет создан, в параметрах команды `board_id = 4216` + +#### Scenario: Спринт на канбане +- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}` +- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет» + +#### Scenario: Тип доски определён +- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}` +- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban` diff --git a/openspec/changes/projects/specs/data-model/spec.md b/openspec/changes/projects/specs/data-model/spec.md new file mode 100644 index 0000000..64668b1 --- /dev/null +++ b/openspec/changes/projects/specs/data-model/spec.md @@ -0,0 +1,156 @@ +## ADDED Requirements + +### Requirement: Проекты +Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: +`label` (PK, метка Jira), `name`, `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — +тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория +Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть +непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL +записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания +хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: +`project = AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем +label, через `, `; без проектов базовой JQL +нет (`null`). + +Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без +метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в +зеркале. Признак `neighbor` в выдаче в разрезе проекта SHALL считаться относительно P: `false` у своих, `true` у соседей. + +#### Scenario: Базовая JQL из двух проектов +- **WHEN** заведены проекты `AWPCA` (раньше) и `STOCKS_DRRK`, `project_key = AURORA` +- **THEN** базовая JQL — `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` + +#### Scenario: Свои и соседи +- **WHEN** у AURORA-1 метка `STOCKS_DRRK` и эпик AURORA-9 (метка `AWPCA`), связь AURORA-1 → AURORA-2 (метка `AWPCA`), + у AURORA-3 метка `AWPCA` без связей с проектом `STOCKS_DRRK` +- **THEN** в разрезе `STOCKS_DRRK` свои — AURORA-1, соседи — AURORA-2 и AURORA-9; AURORA-3 в разрез не входит + +#### Scenario: Метка с пробелом +- **WHEN** создаётся проект с меткой `STOCKS DRRK` +- **THEN** отказ с указанием поля `label`, проекта нет + +#### Scenario: Метка с обратной косой чертой или управляющим символом +- **WHEN** создаётся проект с меткой `A\B` или `A<перевод строки>B` +- **THEN** отказ с указанием поля `label`, проекта нет + +#### Scenario: Метка — зарезервированное слово JQL +- **WHEN** заведён проект с меткой `EMPTY` +- **THEN** базовая JQL — `project = AURORA AND labels in ("EMPTY")`, метка не расширяет выборку + +#### Scenario: Та же метка другим регистром +- **WHEN** есть проект `AWPCA`, создаётся проект `awpca` +- **THEN** отказ «проект с такой меткой уже есть», проект один + +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`, +`command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 4). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени +комментариев (строки Jira как есть). + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version = 4` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 4` + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, + пакеты на месте; `schema_version = 4` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Пакеты и команды +Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`, `project` (метка +проекта, если пакет поставлен в разрезе проекта, иначе `NULL`). +Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` +(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи), +`deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`, +`rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). +Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе — +«выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»; +все терминальные без отмены — «завершён». + +#### Scenario: Статус пакета +- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` +- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` + +#### Scenario: Отменённый пакет с выполненной командой +- **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2 +- **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled` + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование +«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме +передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции); +`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт + +#### Scenario: Запись базовой JQL +- **WHEN** `PUT /settings {base_jql: "project = AURORA"}` +- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился + +#### Scenario: Создание проекта не трогает версию настроек +- **WHEN** создан проект `STOCKS_DRRK` +- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками diff --git a/openspec/changes/projects/specs/hub-api/spec.md b/openspec/changes/projects/specs/hub-api/spec.md new file mode 100644 index 0000000..76f7a27 --- /dev/null +++ b/openspec/changes/projects/specs/hub-api/spec.md @@ -0,0 +1,185 @@ +## ADDED Requirements + +### Requirement: Маршруты проектов +Хаб SHALL предоставлять под клиентским токеном: +- `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; + `issues_count` — задачи зеркала с меткой проекта. +- `POST /projects` `{label, name, board_id?, author?}` → 201 `{project, batch_id}`: в одной транзакции проект + записывается и ставится пакет «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку; + автор пакета — `author` (по умолчанию `api`). Неверные поля (см. `data-model`, «Проекты») — 422 с указанием поля; + метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакета нет. + +#### Scenario: Новый проект +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` +- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`; в очереди пакет автора `viewer` + с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` + +#### Scenario: Проект без доски +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` +- **THEN** 201, `board_id = null` + +#### Scenario: Повтор метки +- **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть +- **THEN** 409, пакет не поставлен + +#### Scenario: Без названия +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}` +- **THEN** 422 с указанием поля `name` + +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `project` (разрез по проекту, см. `data-model`, «Проекты»: свои и соседи, `neighbor` + относительно проекта; неизвестный проект — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, + `epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку + `closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` + и `work_kind` из заметок; сокращение до карточки — дело интерфейса. Без `project` — всё зеркало, `neighbor` — признак + зеркала (вне базовой JQL). +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней, + `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию). +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, project?, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, + неверных параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более + `ensure_issue` с одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся + темы), по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен, доска для + `ensure_sprint`), а также по проверкам пакета проекта (см. `commands`), если передан `project`. +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=&project=` (с `project` — только пакеты этого проекта), `GET /batches/{id}` (с командами и + `project`), `POST /batches/{id}/cancel` — отменяет `queued` команды пакета; ответ — вид пакета с полем `still_claimed`: + список `{id, type}` команд пакета в `claimed` (они будут выполнены агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` `{author?, project?}` — новая команда с теми же параметрами в новом пакете «Повтор + команды N»; хаб снимает с параметров то, что дописывает сам (`board_id` у `ensure_sprint`), и проверяет команду + заново. `project` передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не + передан — новый пакет получает проект исходного и проходит его проверки. +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`; `full` — по базовой JQL из проектов, без + проектов — 422 «нет проектов». +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Список проекта +- **WHEN** свои задачи `STOCKS_DRRK` — AURORA-1 и AURORA-4, сосед — AURORA-2; `GET /issues?project=STOCKS_DRRK` +- **THEN** три задачи, у AURORA-2 `neighbor = true`; с `neighbor=1` — только AURORA-2; задач только с меткой `AWPCA` нет + +#### Scenario: Неизвестный проект +- **WHEN** `GET /issues?project=NOPE` +- **THEN** 404 + +#### Scenario: Пакеты проекта +- **WHEN** пакет 10 поставлен с `project = STOCKS_DRRK`, пакет 11 — без проекта; `GET /batches?project=STOCKS_DRRK` +- **THEN** в ответе только пакет 10 + +#### Scenario: Повтор чужой команды +- **WHEN** команда 40 из пакета проекта `AWPCA`; `POST /commands/40/retry {project: "STOCKS_DRRK"}` +- **THEN** 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан + +#### Scenario: Повтор ensure_sprint +- **WHEN** `ensure_sprint` 41 failed, в её `params` `board_id = 4216`; `POST /commands/41/retry` +- **THEN** новый пакет создан, `board_id` в новой команде вычислен хабом заново + +#### Scenario: Полный прогон без проектов +- **WHEN** проектов нет, `POST /refresh {full: true}` +- **THEN** 422 «нет проектов», пакет не поставлен + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +#### Scenario: Без закрытых +- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без + статуса; `GET /issues?hide_closed=1` +- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре + +### Requirement: Автообновление зеркала +Если `auto_refresh_minutes > 0`, агент на связи и есть хотя бы один проект, хаб SHALL поставить пакет «Автообновление» +из `refresh {jql: <базовая JQL из проектов>}`, когда в очереди нет команд в `queued` и `claimed` и последний `sync_run` +начат раньше, чем `auto_refresh_minutes` назад (или `sync_run` ещё нет). + +#### Scenario: Очередь занята +- **WHEN** интервал прошёл, но в очереди есть `queued` команды +- **THEN** автообновление не ставится до опустошения очереди + +#### Scenario: Есть выданная команда без отчёта +- **WHEN** интервал прошёл, `queued` нет, одна команда в `claimed` +- **THEN** автообновление не ставится; команда довыдаётся при следующем `claim` + +#### Scenario: Недавнее обновление +- **WHEN** очередь пуста, `auto_refresh_minutes = 60`, последний `sync_run` начат 20 минут назад +- **THEN** автообновление не ставится + +#### Scenario: Два проекта +- **WHEN** интервал прошёл, очередь пуста, проекты `AWPCA` и `STOCKS_DRRK` +- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` + +### Requirement: Снимок доски +Хаб SHALL предоставлять `GET /board?project=` под клиентским токеном — согласованный снимок зеркала для доски связей, +прочитанный в одной транзакции: +- `generated_at` — момент снимка (UTC); +- `last_sync` — последний завершённый полный прогон (`refresh` по текущей базовой JQL из проектов): + `{started_at, finished_at, issues_count}` или `null`, если такого не было; общий для всех проектов; +- `issues` — с `project` — свои задачи проекта и его соседи (см. `data-model`, «Проекты»), `neighbor` относительно + проекта; без `project` — все задачи зеркала, включая эпики и соседей, `neighbor` — признак зеркала. Поля: `key`, + `summary`, `issue_type`, `status`, `priority`, `assignee`, `sprint`, `duedate`, `planned_start`, `planned_end`, + `epic_key`, `labels` (список), `neighbor` (bool), `fact_status`, `work_kind` (из заметок, `null` если заметки нет); + порядок — по ключу; +- `links` — с `project` — связи, у которых хотя бы один конец — своя задача проекта; без `project` — все связи + зеркала: `{source, target, link_type}`, `link_type` — имя типа в Jira, направление как в `issue_link` (`source` + блокирует/ссылается на `target`). +Неизвестный проект — 404. Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело +интерфейса; хаб отдаёт данные зеркала без преобразований. + +#### Scenario: Снимок с задачами и связью +- **WHEN** в зеркале эпик AURORA-1351, задачи AURORA-1620 (метка `kind-dev`, эпик AURORA-1351) и AURORA-1621, связь + `AURORA-1620 → AURORA-1621` типа `ASUPR_Блокер_Начала`, заметка `work_kind = "разработка"` у AURORA-1620 +- **THEN** `GET /board` отдаёт три задачи по ключу, у AURORA-1620 `labels = ["kind-dev"]`, `epic_key = "AURORA-1351"`, + `work_kind = "разработка"`; в `links` — `{source: "AURORA-1620", target: "AURORA-1621", link_type: "ASUPR_Блокер_Начала"}` + +#### Scenario: Снимок проекта +- **WHEN** AURORA-1 (метка `STOCKS_DRRK`) связана с AURORA-2 (метка `AWPCA`), AURORA-2 связана с AURORA-3 (метка `AWPCA`); + `GET /board?project=STOCKS_DRRK` +- **THEN** `issues` — AURORA-1 (`neighbor = false`) и AURORA-2 (`neighbor = true`); `links` — только AURORA-1 ↔ AURORA-2 + +#### Scenario: Последний полный прогон +- **WHEN** в журнале синхронизаций полный прогон, завершённый в 10:00, и после него обновление по ключам в 11:00 +- **THEN** `last_sync.finished_at` — 10:00 + +#### Scenario: Без токена +- **WHEN** `GET /board` без токена или с агентским токеном +- **THEN** 401 diff --git a/openspec/changes/projects/specs/mcp/spec.md b/openspec/changes/projects/specs/mcp/spec.md new file mode 100644 index 0000000..0203454 --- /dev/null +++ b/openspec/changes/projects/specs/mcp/spec.md @@ -0,0 +1,134 @@ +## ADDED Requirements + +### Requirement: Привязка сессии к проекту +`jira-hub mcp --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове +инструмента по `GET /projects`: метки нет — ответ «проект <метка> не заведён в хабе: project_create или исправьте +--project в .mcp.json» (кроме `projects`, `project_create`). + +В привязанной к проекту P сессии: +- `issues` — `GET /issues?project=P` с остальными фильтрами; +- `issue` — любая задача по ключу; +- `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:` (его задача создаётся пакетом + проекта), иначе ответ « не в проекте P»; +- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого + проекта или пакета без проекта — отказ хаба); +- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения; +- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше. + +Без привязки инструменты `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать +«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`, +`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, +`batch_cancel` работают. + +#### Scenario: Сессия без проекта +- **WHEN** MCP запущен без `--project`, вызван `issues` +- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /issues` нет + +#### Scenario: Задачи своего проекта +- **WHEN** MCP запущен с `--project STOCKS_DRRK`, вызван `issues` +- **THEN** в таблице только свои задачи `STOCKS_DRRK` и их соседи + +#### Scenario: Создание задачи без метки +- **WHEN** сессия `STOCKS_DRRK`, `batch_create {commands: [ensure_issue {temp_key: new:1, summary: "…"}]}` без `labels` +- **THEN** пакет поставлен с `project = STOCKS_DRRK`, у `ensure_issue` `labels = ["STOCKS_DRRK"]` + +#### Scenario: Опечатка в проекте +- **WHEN** MCP запущен с `--project STOKS`, такого проекта нет; вызван `issues` +- **THEN** ответ «проект STOKS не заведён в хабе: …» + +## MODIFIED Requirements + +### Requirement: Сервер и регистрация +`jira-hub mcp [--project <метка>]` SHALL запускать MCP-сервер по stdio (пакет `mcp`), ходящий в HTTP API хаба клиентским +токеном из настроек. Общая регистрация — в 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 продолжает работать + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» diff --git a/openspec/changes/projects/specs/migration/spec.md b/openspec/changes/projects/specs/migration/spec.md new file mode 100644 index 0000000..58715e1 --- /dev/null +++ b/openspec/changes/projects/specs/migration/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Инициализация настроек +`jira-hub init --config ` SHALL перенести в `setting`: `project_key`, типы задач, +`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`, +`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`; сгенерировать два токена; завести проект +`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`, +`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента). + +#### Scenario: Повторный init +- **WHEN** `init` вызван на уже инициализированной базе +- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется + +#### Scenario: Первый init +- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216` +- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql` и `board_id` diff --git a/openspec/changes/projects/specs/web-app/spec.md b/openspec/changes/projects/specs/web-app/spec.md new file mode 100644 index 0000000..0c648ef --- /dev/null +++ b/openspec/changes/projects/specs/web-app/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: Проект в приложении +Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в +адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть +в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. +Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и выпадающий список +проектов вместо данных. Проектов нет — доска и список SHALL показывать приглашение создать проект («+ Новый проект…»). +Переходы «Доска» / «Задачи» SHALL сохранять текущий проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит. + +Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — как прежде, `POST /refresh {full: true}`, +обновляет все проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта; +у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов, сохраняется). Фильтр спринтов SHALL +показываться, только если хотя бы у одной своей задачи снимка (`neighbor = false`) есть спринт; иначе — «колонки +«Спринт» нет — показаны все задачи». + +Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) SHALL открываться окном поверх страницы: метка, +название, номер доски Jira (необязательно; подсказка «scrum или kanban определится сам»). «Создать» — `POST /projects` +с автором `viewer`; ошибка хаба 409/422 — текст `detail` рядом с кнопкой, окно остаётся открытым; успех — окно +закрывается, текущим становится новый проект, доска подхватывает пакет обновления (`refresh` автора `viewer`) и +показывает его ход. + +#### Scenario: Первый вход после выкатки +- **WHEN** открыт `/app/board` без `project`, в `localStorage` выбора нет, проекты `AWPCA` и `STOCKS_DRRK` +- **THEN** адрес — `/app/board?project=AWPCA`, доска — `GET /board?project=AWPCA` с прежней раскладкой карточек + +#### Scenario: Переключение проекта +- **WHEN** на доске `AWPCA` в выпадающем списке выбран `STOCKS_DRRK`, затем открыт раздел «Задачи» +- **THEN** доска — `GET /board?project=STOCKS_DRRK` со своей раскладкой; адрес «Задач» — `/app/issues?project=STOCKS_DRRK` + +#### Scenario: Неизвестный проект в адресе +- **WHEN** открыт `/app/issues?project=NOPE` +- **THEN** «проект NOPE не найден» и список проектов; запроса `GET /issues` нет + +#### Scenario: Создание проекта из формы +- **WHEN** в форме введены `STOCKS_DRRK`, «Запасы ДРРК», доска пусто; нажато «Создать» +- **THEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; текущий проект — `STOCKS_DRRK`; + шапка доски показывает «пакет #N выполняется» + +#### Scenario: Метка занята +- **WHEN** в форме введена метка `awpca`, хаб ответил 409 +- **THEN** окно открыто, рядом с «Создать» — текст ошибки хаба + +#### Scenario: Канбан-проект +- **WHEN** текущий проект — `STOCKS_DRRK`, ни у одной своей задачи снимка нет спринта (у соседа из `AWPCA` спринт есть) +- **THEN** вместо фильтра спринтов — «колонки «Спринт» нет — показаны все задачи» + +## MODIFIED Requirements + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список +задач) и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь — +на страницу «не найдено» со ссылками на разделы. Верхняя панель SHALL содержать выпадающий список проектов рядом с +названием («<метка> — <название>», текущий отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»), +переходы «Доска», «Задачи» и действие «Сменить токен». Неожиданная ошибка отрисовки страницы SHALL показываться +сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Переход между разделами +- **WHEN** на доске выбран раздел «Задачи» +- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +### Requirement: Список задач +Страница `/app/issues` SHALL показывать задачи текущего проекта из `GET /issues?project=<текущий>` таблицей: ключ (открывает карточку задачи окном поверх списка), тема, +тип, статус Jira, исполнитель, спринт, срок (`planned_end`, иначе `duedate`), эпик (имя эпика, иначе ключ), вид работы, +признак «сосед» (относительно проекта); статуса по факту в списке нет. Фильтры SHALL соответствовать параметрам `GET /issues` (спринт, исполнитель, вид +работы, статус Jira, метка, эпик, только соседи, поиск по ключу или теме, скрыть закрытые), храниться в query-строке +адреса вместе с `project` и применяться сервером; варианты выпадающих списков SHALL браться из снимка `GET /board?project=<текущий>`; значение фильтра из адреса, которого нет среди +вариантов, SHALL показываться в списке с пометкой «(нет на доске)». Флажок «скрыть закрытые» SHALL +быть включён по умолчанию (в адресе ничего), снятый — `closed=1` в адресе; включённый — запрос с `hide_closed=1`, +кроме случая, когда в фильтре «Статус Jira» выбран статус из `closed_statuses`. Параметр `fact_status` в адресе +игнорируется. Задачи SHALL +загружаться порциями по 50 строк; «Показать ещё» SHALL быть доступна, пока последняя порция полная. + +#### Scenario: Фильтр в адресе +- **WHEN** выбран спринт «AWPCA_Спринт 2» и исполнитель +- **THEN** адрес содержит `project=`, `sprint=` и `assignee=`, таблица — результат `GET /issues` с этими параметрами; открытие этого + адреса в новой вкладке показывает ту же выборку + +#### Scenario: Конец списка +- **WHEN** вторая порция вернула 17 задач +- **THEN** в таблице 67 задач, «Показать ещё» не показана + +#### Scenario: Фильтр по закрытому спринту +- **WHEN** открыт адрес `/app/issues?project=AWPCA&sprint=AWPCA_Спринт 0`, а в снимке доски такого спринта нет +- **THEN** выпадающий список «Спринт» показывает «AWPCA_Спринт 0 (нет на доске)» выбранным, таблица — по этому фильтру + +#### Scenario: Карточка поверх списка +- **WHEN** список отфильтрован по спринту, прокручен и догружен «Показать ещё», открыта карточка задачи и окно закрыто +- **THEN** адрес списка с тем же фильтром, в таблице те же строки, прокрутка та же + +#### Scenario: Закрытые скрыты по умолчанию +- **WHEN** открыт `/app/issues`, в зеркале задачи в статусах «В работе», «Готово», «Снято» +- **THEN** флажок «скрыть закрытые» включён, запрос `GET /issues` с `hide_closed=1`, в таблице только «В работе»; после + снятия флажка адрес содержит `closed=1`, в таблице все три + +#### Scenario: Выбран закрытый статус +- **WHEN** флажок «скрыть закрытые» включён и в «Статус Jira» выбрано «Готово» +- **THEN** запрос `GET /issues?status=Готово` без `hide_closed`, в таблице задачи в статусе «Готово» diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 4826f90..95362d0 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, files, mirror, queue, relay, release, settings +from . import backup, db, files, mirror, projects, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -180,7 +180,7 @@ 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") - return 200, {**cmd, "params": queue.deliver_params(cmd, hub.files_dir)} + return 200, {**cmd, "params": queue.deliver_params(conn, cmd, hub.files_dir)} def h_agent_result(hub, conn, m, q, body): @@ -214,11 +214,23 @@ 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") +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), @@ -230,7 +242,7 @@ def h_issue(hub, conn, m, q, body): def h_board(hub, conn, m, q, body): - return 200, mirror.board(conn, settings.get(conn, "base_jql")) + return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q)) def h_app(hub, conn, m, q, body): @@ -274,14 +286,19 @@ def h_batches_post(hub, conn, m, q, body): raise ApiError(422, "bad_batch", "author — строка") if not isinstance(body.get("commands"), list): raise ApiError(422, "bad_batch", "commands — список команд") + if body.get("project") is not None and not isinstance(body["project"], str): + raise ApiError(422, "bad_batch", "project — строка") + if body.get("project") == "": + raise ApiError(422, "bad_batch", "project: пустая строка") try: - return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"), files_dir=hub.files_dir) + 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 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))} + 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): @@ -317,8 +334,13 @@ def h_files_post(hub, conn, m, q, body): def h_command_retry(hub, conn, m, q, body): if not isinstance(body.get("author") or "api", str): raise ApiError(422, "bad_retry", "author — строка") + if body.get("project") is not None and not isinstance(body["project"], str): + raise ApiError(422, "bad_retry", "project — строка") + 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(m), body.get("author") or "api", files_dir=hub.files_dir, + project=body.get("project")) except KeyError: raise ApiError(404, "not_found", "command") except CommandError as e: @@ -343,7 +365,10 @@ 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"): - params = {"jql": settings.get(conn, "base_jql")} + jql = 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"): @@ -369,7 +394,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) + return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)} def h_settings_put(hub, conn, m, q, body): @@ -387,7 +412,33 @@ 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) + return 200, {**settings.get_all(conn), "base_jql": projects.base_jql(conn)} + + +def h_projects_get(hub, conn, m, q, body): + return 200, {"projects": projects.list_projects(conn)} + + +def h_projects_post(hub, conn, m, q, body): + """Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной + транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (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 — строка") + 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": projects.base_jql(conn)}}]) + 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"]} ROUTES = [ @@ -417,6 +468,8 @@ ROUTES = [ ("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"), ] _COMPILED = [(m, re.compile(p), fn, scope) for m, p, fn, scope in ROUTES] diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index ce90d04..de3a4a1 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -4,7 +4,7 @@ import argparse import json import sys -from . import db, importer, paths, settings +from . import db, importer, paths, projects, settings def main(argv=None) -> int: @@ -18,7 +18,8 @@ def main(argv=None) -> int: p.add_argument("--port", type=int) p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") p.add_argument("folder") - sub.add_parser("mcp", help="MCP-сервер по stdio") + 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) @@ -46,7 +47,7 @@ def main(argv=None) -> int: return 0 if args.cmd == "mcp": from . import mcp_server - mcp_server.run() + mcp_server.run(args.project) return 0 conn = db.connect(paths.db_path()) @@ -61,6 +62,9 @@ def main(argv=None) -> int: 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": diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 0e091b6..246604f 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 3 +SCHEMA_VERSION = 4 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -68,7 +68,8 @@ CREATE TABLE IF NOT EXISTS batch ( author TEXT NOT NULL, created_at TEXT NOT NULL, cancelled_at TEXT, - estimate_seconds INTEGER NOT NULL DEFAULT 0 + estimate_seconds INTEGER NOT NULL DEFAULT 0, + project TEXT ); CREATE TABLE IF NOT EXISTS command ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -100,6 +101,13 @@ CREATE TABLE IF NOT EXISTS sync_run ( rate_limited INTEGER NOT NULL DEFAULT 0, command_id INTEGER ); +CREATE TABLE IF NOT EXISTS project ( + label TEXT PRIMARY KEY, + name TEXT NOT NULL, + board_id INTEGER, + board_type TEXT, + created_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS agent_status ( id INTEGER PRIMARY KEY CHECK (id = 1), last_seen_at TEXT, @@ -110,11 +118,17 @@ CREATE TABLE IF NOT EXISTS agent_status ( ); """ +def _migrate_projects(conn) -> None: + from . import projects # внутри функции: projects импортирует db + projects.migrate_v3(conn) + + # Шаги миграции: версия → 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), данных переносить не нужно + 3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки } @@ -164,8 +178,8 @@ def migrate(conn: sqlite3.Connection, path=None) -> None: finally: reader.close() while current < SCHEMA_VERSION: - for sql in MIGRATIONS[current]: - conn.execute(sql) + for step in MIGRATIONS[current]: + step(conn) if callable(step) else conn.execute(step) current += 1 conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 157c8bb..dd4400d 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -12,16 +12,28 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP from .client import HubClient, HubError, HubUnavailable -from .commands import describe +from .commands import describe, is_temp from .files import MAX_SIZE 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[2] # корень репозитория хаба, из которого загружен пакет (решение 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() @@ -54,6 +66,33 @@ def guard(fn): 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): @@ -65,6 +104,16 @@ 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 "задач нет" @@ -137,17 +186,19 @@ def fmt_agent(a) -> str: @mcp.tool() @guard +@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", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + data = _client().get("/issues", project=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) def issue(key: str) -> str: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" return fmt_issue(_client().get(f"/issues/{key}")) @@ -155,12 +206,16 @@ def issue(key: str) -> str: @mcp.tool() @guard +@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 + 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) 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)) @@ -232,11 +287,11 @@ def _doc(text: str): @mcp.tool() @guard +@scoped(True) @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. Параметры по типам (необязательные — в [ ]): """ + describe() + """ -ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, -задача без неё выпадет из базовой JQL и из зеркала. +Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб. ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: @@ -244,12 +299,13 @@ def batch_create(title: str, commands: list[dict]) -> str: prepared = _attach_files(client, commands) if isinstance(prepared, str): return prepared - b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared}) + b = client.post("/batches", {"title": title, "author": AUTHOR, "project": PROJECT, "commands": prepared}) return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" @mcp.tool() @guard +@scoped(False) def batch_status(id: int) -> str: """Состояние пакета и его команд.""" return fmt_batch(_client().get(f"/batches/{id}")) @@ -257,6 +313,7 @@ def batch_status(id: int) -> str: @mcp.tool() @guard +@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 @@ -270,22 +327,25 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str: @mcp.tool() @guard +@scoped(False) def batches(limit: int = 10) -> str: """Последние пакеты со статусом.""" - items = _client().get("/batches", limit=limit)["batches"] + items = _client().get("/batches", project=PROJECT, limit=limit)["batches"] return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" @mcp.tool() @guard +@scoped(True) def command_retry(id: int) -> str: """Повторить команду — новый пакет с теми же параметрами.""" - b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR}) + b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR, "project": PROJECT}) return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" @mcp.tool() @guard +@scoped(False) def batch_cancel(id: int) -> str: """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — в ответе подсказка (POST /commands/N/cancel с force).""" @@ -294,6 +354,7 @@ def batch_cancel(id: int) -> str: @mcp.tool() @guard +@scoped(True) async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; wait=true — дождаться результата (до 30 мин).""" @@ -305,6 +366,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool @mcp.tool() @guard +@scoped(False) def agent_status() -> str: """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, состояние самообновления и запускатель.""" @@ -313,6 +375,7 @@ def agent_status() -> str: @mcp.tool() @guard +@scoped(False) def sync_log(limit: int = 10) -> str: """Журнал обновлений зеркала.""" runs = _client().get("/sync-runs", limit=limit)["runs"] @@ -323,6 +386,7 @@ def sync_log(limit: int = 10) -> str: @mcp.tool() @guard +@scoped(False) def settings_get(key: str = "") -> str: """Настройки хаба (все или одна по ключу).""" data = _client().get("/settings") @@ -331,6 +395,7 @@ def settings_get(key: str = "") -> str: @mcp.tool() @guard +@scoped(False) def settings_set(key: str, value: str) -> str: """Записать настройку: value — JSON. Поднимает settings_version.""" try: @@ -341,5 +406,30 @@ def settings_set(key: str, value: str) -> str: return f"settings_version = {out['settings_version']}" -def run() -> None: +@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") diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index d616962..ae7503f 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -80,9 +80,31 @@ def _issue_dict(row) -> dict: return d +# Разрез по проекту: свои задачи (метка проекта — точное совпадение), их соседи по связям в обе стороны и эпики своих. +# Единственный параметр — метка, первым в списке аргументов запроса. +SCOPE_CTE = """WITH own(key) AS ( + SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)), +near(key) AS ( + SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own) + UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own) + UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL), +scope(key, nb) AS ( + SELECT key, 0 FROM own + UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own)) +""" + + +def labels_of(conn, key: str) -> list[str] | None: + """Метки задачи зеркала; None — задачи в зеркале нет.""" + row = conn.execute("SELECT labels FROM issue WHERE key = ?", (key,)).fetchone() + return None if row is None else json.loads(row["labels"] or "[]") + + def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> list[dict]: - """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта).""" + """hide_closed — без задач, чей status из closed_statuses (задача без статуса не закрыта). + filters["project"] — разрез по проекту (SCOPE_CTE): neighbor — относительно проекта.""" filters = filters or {} + project = filters.get("project") or None where, args = [], [] for name, sql in _FILTERS.items(): if filters.get(name) not in (None, ""): @@ -92,7 +114,7 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l where.append("EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)") args.append(filters["label"]) if filters.get("neighbor") not in (None, ""): - where.append("i.neighbor = ?") + where.append("s.nb = ?" if project else "i.neighbor = ?") args.append(1 if filters["neighbor"] in (True, 1, "1", "true") else 0) if filters.get("hide_closed") in (True, 1, "1", "true") and closed_statuses: where.append(f"(i.status IS NULL OR i.status NOT IN ({', '.join('?' * len(closed_statuses))}))") @@ -100,10 +122,20 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l if filters.get("q"): where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] - sql = ("SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + if project: + sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i " + "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key") + args = [project] + args + else: + sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + sql = (sql + (" WHERE " + " AND ".join(where) if where else "") + " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?") - return [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + out = [_issue_dict(r) for r in conn.execute(sql, args + [int(limit), int(offset)])] + if project: # i.* уже несёт neighbor зеркала — в разрезе он относительно проекта + for d in out: + d["neighbor"] = bool(d.pop("scope_nb")) + return out def get_issue(conn, key) -> dict | None: @@ -133,20 +165,31 @@ BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee" "planned_end", "epic_key", "labels", "neighbor"] -def board(conn, base_jql) -> dict: +def board(conn, base_jql, project: str | None = None) -> dict: """Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски - делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" + делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых + source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" own = not conn.in_transaction if own: conn.execute("BEGIN") try: - issues = [_issue_dict(r) for r in conn.execute( - f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind " - "FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")] - links = [dict(r) for r in conn.execute( - "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " - "ORDER BY source_key, target_key, link_type")] + if project: + cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor") + issues = [_issue_dict(r) for r in conn.execute( + SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i " + "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))] + links = [dict(r) for r in conn.execute( + SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " + "WHERE source_key IN (SELECT key FROM own) OR target_key IN (SELECT key FROM own) " + "ORDER BY source_key, target_key, link_type", (project,))] + else: + issues = [_issue_dict(r) for r in conn.execute( + f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind " + "FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")] + links = [dict(r) for r in conn.execute( + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " + "ORDER BY source_key, target_key, link_type")] run = conn.execute( "SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id " "WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' " diff --git a/src/jira_hub/projects.py b/src/jira_hub/projects.py new file mode 100644 index 0000000..7e0d4bb --- /dev/null +++ b/src/jira_hub/projects.py @@ -0,0 +1,172 @@ +"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4.""" +import logging +import re +import unicodedata + +from . import db, settings + +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" + + +class ProjectError(ValueError): + """Ошибка данных проекта; field — label | name | board_id.""" + + def __init__(self, field: str, reason: str): + super().__init__(f"{field}: {reason}") + self.field = field + + +class ProjectExists(ProjectError): + def __init__(self, label: str): + super().__init__("label", f"проект с такой меткой уже есть: {label}") + + +def _label_error(label) -> str | None: + if not isinstance(label, str) or not label: + return "ожидается непустая строка" + if len(label) > MAX_LEN: + return f"длиннее {MAX_LEN} символов" + if any(c.isspace() or c in _FORBIDDEN or unicodedata.category(c) == "Cc" for c in label): + return "пробелы, управляющие символы и символы , \" ' ( ) \\ недопустимы" + return None + + +def validate(label, name, board_id) -> tuple[str, str, int | None]: + """Проверить поля проекта; вернуть (метка, название без пробелов по краям, доска).""" + error = _label_error(label) + if error: + raise ProjectError("label", error) + if not isinstance(name, str) or not name.strip(): + raise ProjectError("name", "ожидается непустая строка") + if len(name.strip()) > MAX_LEN: + raise ProjectError("name", f"длиннее {MAX_LEN} символов") + 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 + + +def get(conn, label: str) -> dict | None: + row = conn.execute(f"SELECT {_FIELDS} FROM project WHERE label = ?", (label,)).fetchone() + return None if row is None else dict(row) + + +def create(conn, label: str, name: str, board_id: int | None = None) -> dict: + label, name, board_id = validate(label, name, board_id) + with db.transaction(conn): + if label.casefold() in {x.casefold() for x in labels(conn)}: + raise ProjectExists(label) + 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]: + 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 " + "FROM project ORDER BY created_at, label").fetchall() + return [dict(r) for r in rows] + + +def light_list(conn) -> list[dict]: + """Проекты без issues_count (label, board_id, board_type), порядок как у list_projects.""" + 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/queue.py b/src/jira_hub/queue.py index 66d2dcc..a407b70 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -7,7 +7,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C -from . import db, files, mirror, settings +from . import db, files, mirror, projects, settings log = logging.getLogger(__name__) @@ -67,10 +67,85 @@ def _check_setup(conn, cmds, files_dir) -> None: 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 и др.)") + _assign_boards(conn, cmds) -def create_batch(conn, title, author, cmds, files_dir=None) -> dict: +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 != "refresh": + 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 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: @@ -79,8 +154,8 @@ def create_batch(conn, title, author, cmds, files_dir=None) -> dict: cmds.append({"type": "refresh", "params": {"keys": keys}}) est = C.estimate([c["type"] for c in cmds], settings.pacing(conn)) with db.transaction(conn): - cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds) VALUES (?, ?, ?, ?)", - (title, author or "api", db.now(), est)) + 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): @@ -118,8 +193,9 @@ def batch_view(conn, batch_id, with_commands=True) -> dict | None: return d -def list_batches(conn, limit=20) -> list[dict]: - ids = [r["id"] for r in conn.execute("SELECT id FROM batch ORDER BY id DESC LIMIT ?", (int(limit),))] +def list_batches(conn, limit=20, project=None) -> list[dict]: + where, args = ("WHERE project = ?", [project]) if project is not None else ("", []) + ids = [r["id"] for r in conn.execute(f"SELECT id FROM batch {where} ORDER BY id DESC LIMIT ?", (*args, int(limit)))] return [batch_view(conn, i, with_commands=False) for i in ids] @@ -186,9 +262,12 @@ def _redeliver(conn, now) -> dict | None: log.warning("команда %s: агент не отчитался после %s выдач — failed", cmd["id"], MAX_DELIVERIES) -def deliver_params(cmd, files_dir) -> dict: +def deliver_params(conn, cmd, files_dir) -> dict: """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, - если файла уже нет (решение 5). В базе params остаются без size: повтор команды проходит проверку как новая.""" + если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них: + повтор команды проходит проверку как новая.""" + if cmd["type"] == "refresh": + return {**cmd["params"], "boards": projects.boards(conn)} 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 @@ -237,7 +316,7 @@ def claim(conn, agent_version=None, settings_version=None, last_jira_call_at=Non backup(to_backup) except Exception: # сбой копии не должен держать очередь: команда уже взята log.warning("резервная копия %s не создана, команда %s выдаётся без неё", to_backup, cmd["id"], exc_info=True) - return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(cmd, files_dir), "pause_seconds": pause, + return {"id": cmd["id"], "type": cmd["type"], "params": deliver_params(conn, cmd, files_dir), "pause_seconds": pause, "batch_id": cmd["batch_id"]} @@ -248,8 +327,8 @@ def is_full_refresh(conn, command_id) -> bool: if not command_id: return False cmd = get_command(conn, command_id) - return bool(cmd and cmd["type"] == "refresh" and cmd["params"].get("jql") - and cmd["params"]["jql"] == settings.get(conn, "base_jql")) + 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: @@ -289,6 +368,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= 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: + projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем if status == "done" and is_full_refresh(conn, command_id): # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) @@ -349,12 +430,16 @@ def cancel_command(conn, command_id, force=False) -> dict: return get_command(conn, command_id) -def retry_command(conn, command_id, author, files_dir=None) -> dict: +def retry_command(conn, command_id, author, files_dir=None, project=None) -> dict: cmd = get_command(conn, command_id) if cmd is None: raise KeyError(command_id) - return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": cmd["params"]}], - files_dir=files_dir) + 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")} + return create_batch(conn, f"Повтор команды {command_id}", author, [{"type": cmd["type"], "params": params}], + files_dir=files_dir, project=origin) # --- агент и автообновление ---------------------------------------------------- @@ -373,6 +458,9 @@ def agent_view(conn) -> dict: 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 agent_view(conn)["online"]: return None @@ -381,4 +469,4 @@ def maybe_auto_refresh(conn) -> dict | 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": settings.get(conn, "base_jql")}}]) + return create_batch(conn, "Автообновление", "hub", [{"type": "refresh", "params": {"jql": jql}}]) diff --git a/src/jira_hub/settings.py b/src/jira_hub/settings.py index 5a49561..c278a7c 100644 --- a/src/jira_hub/settings.py +++ b/src/jira_hub/settings.py @@ -8,7 +8,6 @@ DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 3 "ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2} DEFAULTS = { - "base_jql": "project = AURORA AND labels = AWPCA", "project_key": "AURORA", "pacing": DEFAULT_PACING, "backup_keep": 30, @@ -29,13 +28,12 @@ DEFAULTS = { "comment_marker": "", "transitions_map": {}, "link_types": {}, - "board_id": None, "relay_dir": None, "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } # Что получает агент (без токенов и служебных ключей хаба) -AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", +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"] @@ -48,11 +46,16 @@ def _is_int(v) -> bool: 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; неизвестные ключи и ключи с умолчанием 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 — объект {тип команды: пауза в секундах}") @@ -153,7 +156,6 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]: project = cfg["project_key"] values = { "project_key": project, - "base_jql": f"project = {project} AND labels = AWPCA", "issue_types": _issue_types(cfg), "epic_name_field_id": cfg.get("epic_name_field_id"), "field_ids": cfg.get("fields", {}), @@ -166,7 +168,6 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]: "comment_marker": cfg.get("comment_marker", ""), "transitions_map": cfg.get("transitions", {}).get("map", {}), "link_types": cfg.get("link_types", {}), - "board_id": cfg.get("sprints", {}).get("board_id"), "pacing": pacing_from_config(cfg.get("pacing", {})), } for key, value in values.items(): diff --git a/tests/conftest.py b/tests/conftest.py index 2ef29b9..b0065ce 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 +from jira_hub import db, projects @pytest.fixture @@ -18,3 +18,16 @@ def open_db(): yield make for conn in opened: conn.close() + + +@pytest.fixture +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 + 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}) + return projects.get(conn, label) + return make diff --git a/tests/test_api.py b/tests/test_api.py index 1351418..07ee54b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,8 +11,9 @@ from datetime import timedelta import pytest -from jira_hub import api, db, files, queue, release, settings +from jira_hub import api, db, files, projects, queue, release, settings from jira_hub.client import HubClient, HubError +from jira_hub.commands import CommandError @pytest.fixture @@ -33,6 +34,14 @@ def hub(tmp_path): th.join(5) +def _add_project(hub, add_project, *args, **kw): + conn = hub["hub"].conn() + try: + return add_project(conn, *args, **kw) + finally: + conn.close() + + def test_auth_scopes(hub): with pytest.raises(HubError) as e: hub["agent"].get("/issues") @@ -93,7 +102,8 @@ def test_cancel_batch_api_reports_still_claimed(hub): assert out["still_claimed"] == [{"id": cmd["id"], "type": "ensure_fields"}] -def test_interface_routes(hub): +def test_interface_routes(hub, add_project): + _add_project(hub, add_project) c = hub["client"] with pytest.raises(HubError) as e: c.get("/issues/A-404") @@ -121,7 +131,7 @@ def test_interface_routes(hub): retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}" full = c.post("/refresh", {"full": True}) - assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels = AWPCA" + assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == 'project = AURORA AND labels in ("AWPCA")' keys = c.post("/refresh", {"keys": ["A-1"]}) assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]} @@ -265,7 +275,7 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub): @pytest.mark.parametrize("body", [ {"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}}, {"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1}, - {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"base_jql": 5}, + {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается ]) @@ -278,15 +288,33 @@ def test_settings_put_invalid_value_is_422(hub, body): assert c.get("/settings") == before +@pytest.mark.parametrize("body, text", [({"base_jql": "project = AURORA"}, "базовая JQL собирается из проектов"), + ({"board_id": 12}, "доска Jira — свойство проекта")]) +def test_settings_put_rejects_jql_and_board(hub, body, text): + c = hub["client"] + v0 = c.get("/settings")["settings_version"] + with pytest.raises(HubError) as e: + c.put("/settings", body) + assert e.value.status == 422 and text in str(e.value.detail) + assert c.get("/settings")["settings_version"] == v0 + + +def test_full_refresh_without_projects(hub): + with pytest.raises(HubError) as e: + hub["client"].post("/refresh", {"full": True}) + assert e.value.status == 422 and "нет проектов" in str(e.value.detail) + assert hub["client"].get("/batches")["batches"] == [] + + def test_settings_put_valid_values_pass(hub): c = hub["client"] v0 = c.get("/settings")["settings_version"] out = c.put("/settings", {"pacing": {"refresh": 0, "ensure_fields": 90}, "backup_keep": 0, "port": 8800, "auto_refresh_minutes": 15, "min_agent_version": 3, "link_types": {"x": "Y"}, - "labels": ["AWPCA"], "board_id": 12}) - assert out["settings_version"] == v0 + 8 + "labels": ["AWPCA"]}) + assert out["settings_version"] == v0 + 7 got = c.get("/settings") - assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 and got["board_id"] == 12 + assert got["pacing"]["ensure_fields"] == 90 and got["port"] == 8800 def _run_refresh(hub, body, issues, neighbor_keys=()): @@ -299,8 +327,9 @@ def _run_refresh(hub, body, issues, neighbor_keys=()): hub["agent"].post(f"/agent/commands/{cmd['id']}/result", {"status": "done"}) -def test_refresh_by_keys_keeps_neighbor_flag(hub): +def test_refresh_by_keys_keeps_neighbor_flag(hub, add_project): """I-2(b): точечное обновление соседней задачи не опускает флаг.""" + _add_project(hub, add_project) _run_refresh(hub, {"full": True}, [{"key": "A-1", "summary": "т", "labels": []}, {"key": "B-1", "summary": "сосед", "labels": []}], neighbor_keys=["B-1"]) assert hub["client"].get("/issues/B-1")["neighbor"] is True @@ -309,8 +338,9 @@ def test_refresh_by_keys_keeps_neighbor_flag(hub): assert issue["summary"] == "сосед 2" and issue["neighbor"] is True -def test_full_refresh_marks_unseen_issue_as_neighbor(hub): +def test_full_refresh_marks_unseen_issue_as_neighbor(hub, add_project): """I-2(a): полный прогон по базовой JQL: не обновлённая им задача получает neighbor=1.""" + _add_project(hub, add_project) conn = hub["hub"].conn() conn.execute("INSERT INTO issue(key, summary, labels, fetched_at) VALUES ('X-1', 'вышла из выборки', '[]', '2020-01-01T00:00:00+00:00')") conn.close() @@ -350,7 +380,8 @@ def test_duplicate_summary_batch_is_422(hub): assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail -def test_board_route(hub): +def test_board_route(hub, add_project): + _add_project(hub, add_project) a, c = hub["agent"], hub["client"] a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": ["kind-dev"]}, {"key": "B-1", "summary": "Сосед", "labels": []}], @@ -517,13 +548,20 @@ def test_agent_reported_even_with_bad_update_fields(hub, tmp_path): assert v["update_hint"] == "агент запущен без run.py — обновится только вручную" +def test_settings_get_base_jql_from_projects(hub, add_project): + c = hub["client"] + assert c.get("/settings")["base_jql"] is None + _add_project(hub, add_project) + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA")' + + def test_settings_put_returns_all_settings(hub): """O-11 #16, решение 9, спека «Ответ на запись настроек»: все настройки после записи, как GET /settings, без токенов.""" c = hub["client"] v0 = c.get("/settings")["settings_version"] out = c.put("/settings", {"relay_dir": "/Volumes/externalSSD/utm/rshb/jira-bridge"}) assert out["relay_dir"] == "/Volumes/externalSSD/utm/rshb/jira-bridge" and out["settings_version"] == v0 + 1 - assert "tokens" not in out and out["base_jql"] == "project = AURORA AND labels = AWPCA" + assert "tokens" not in out and out["base_jql"] is None assert out == c.get("/settings") @@ -568,8 +606,9 @@ def test_agent_warning_and_offline(hub): assert c.get("/agent")["online"] is False -def test_tick_creates_auto_refresh_once(hub): +def test_tick_creates_auto_refresh_once(hub, add_project): """M-10 #4: тик при агенте на связи и пустой очереди ставит «Автообновление»; второй тик — нет (очередь занята).""" + _add_project(hub, add_project) hub["agent"].post("/agent/claim", {"agent_version": 9}) hub["hub"].tick() batches = hub["client"].get("/batches", limit=5)["batches"] @@ -700,7 +739,8 @@ def test_tick_sweeps_files_once_a_day(hub, tmp_path, monkeypatch): assert len(calls) == 2 -def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog): +def test_tick_sweep_failure_logged_auto_refresh_still_runs(hub, monkeypatch, caplog, add_project): + _add_project(hub, add_project) def broken(*args, **kwargs): raise OSError("диск недоступен") monkeypatch.setattr(api.files, "sweep", broken) @@ -742,3 +782,162 @@ def test_issues_hide_closed(hub): assert keys() == ["A-1", "A-2", "A-3", "A-4"] c.put("/settings", {"closed_statuses": ["Готово"]}) assert keys(hide_closed="1") == ["A-1", "A-3", "A-4"] + + +def test_create_project(hub): # сценарий «Новый проект» + c = hub["client"] + conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) + assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None + got = c.get("/projects")["projects"] + assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None + assert b["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'} + assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' + + +def test_create_project_without_board(hub): + c = hub["client"] + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + assert out["project"]["board_id"] is None and out["project"]["board_type"] is None + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "api" + + +@pytest.mark.parametrize("body, status, field", [ + ({"label": "AWPCA", "name": "x"}, 409, "label"), + ({"label": "awpca", "name": "x"}, 409, "label"), + ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), + ({"label": "A\\B", "name": "x"}, 422, "label"), + ({"label": "A\nB", "name": "x"}, 422, "label"), + ({"label": "A\tB", "name": "x"}, 422, "label"), + ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), + ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), + ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": 0}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": False}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": []}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": {}}, 422, None), +]) +def test_create_project_errors(hub, body, status, field): + c = hub["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + before = len(c.get("/batches")["batches"]) + with pytest.raises(HubError) as e: + c.post("/projects", body) + assert e.value.status == status and (field is None or e.value.detail.startswith(field)) + assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before + + +@pytest.mark.parametrize("author", [None, ""]) +def test_create_project_empty_author_is_api(hub, author): + c = hub["client"] + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "x", "author": author}) + assert c.get(f"/batches/{out['batch_id']}")["author"] == "api" + + +def test_create_project_batch_error_rolls_back(hub, monkeypatch): # T3.2 + def boom(*a, **k): + raise CommandError("не ставится") + monkeypatch.setattr(queue, "create_batch", boom) + c = hub["client"] + with pytest.raises(HubError) as e: + c.post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) + assert e.value.status == 422 and e.value.error == "bad_batch" + assert c.get("/projects")["projects"] == [] + + +def test_projects_need_client_token(hub): + with pytest.raises(HubError) as e: + hub["agent"].get("/projects") + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["agent"].post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) + assert e.value.status == 401 + + +def test_board_and_issues_by_project(hub): + c, a = hub["client"], hub["agent"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + a.post("/agent/state", {"issues": [ + {"key": "A-1", "summary": "Т1", "labels": ["STOCKS_DRRK"]}, + {"key": "A-2", "summary": "Т2", "labels": ["AWPCA"]}, + {"key": "A-3", "summary": "Т3", "labels": ["AWPCA"]}], + "links": [{"source_key": "A-1", "target_key": "A-2", "link_type": "L"}]}) + snap = c.get("/board?project=STOCKS_DRRK") + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [("A-1", False), ("A-2", True)] + assert [(l["source"], l["target"]) for l in snap["links"]] == [("A-1", "A-2")] + assert snap["last_sync"] == c.get("/board")["last_sync"] + assert len(c.get("/board")["issues"]) == 3 + assert [i["key"] for i in c.get("/issues?project=STOCKS_DRRK&neighbor=1")["issues"]] == ["A-2"] + for path in ("/issues?project=NOPE", "/board?project=NOPE"): + with pytest.raises(HubError) as e: + c.get(path) + assert e.value.status == 404 and "project NOPE" in e.value.detail + + +def test_project_batches_api(hub, add_project): # #61, Task 5 + conn = hub["hub"].conn() + try: + add_project(conn) + add_project(conn, "STOCKS_DRRK", board_id=None) + finally: + conn.close() + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "А", "labels": ["AWPCA"]}, + {"key": "A-2", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) + c = hub["client"] + cmd = lambda key: [{"type": "ensure_fields", "params": {"key": key, "duedate": "2026-10-23"}}] + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-1")}) + assert e.value.status == 422 and "не в проекте" in e.value.detail + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "project": 5, "commands": cmd("A-2")}) + assert e.value.status == 422 + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "x", "project": "", "commands": cmd("A-2")}) + assert e.value.status == 422 and "project: пустая строка" in e.value.detail + mine = c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-2")}) + c.post("/batches", {"title": "y", "commands": cmd("A-1")}) + assert [b["id"] for b in c.get("/batches", project="STOCKS_DRRK")["batches"]] == [mine["id"]] + assert c.get(f"/batches/{mine['id']}")["project"] == "STOCKS_DRRK" + own = c.post("/batches", {"title": "z", "project": "AWPCA", "commands": cmd("A-1")}) + cid = c.get(f"/batches/{own['id']}")["commands"][0]["id"] + before = len(c.get("/batches", limit=50)["batches"]) + with pytest.raises(HubError) as e: + c.post(f"/commands/{cid}/retry", {"project": "STOCKS_DRRK"}) + assert e.value.status == 422 and f"команда #{cid} не из проекта STOCKS_DRRK" in e.value.detail + with pytest.raises(HubError) as e: + c.post(f"/commands/{cid}/retry", {"project": 5}) + assert e.value.status == 422 + with pytest.raises(HubError) as e: + c.post(f"/commands/{cid}/retry", {"project": ""}) + assert e.value.status == 422 and "project: пустая строка" in e.value.detail + assert len(c.get("/batches", limit=50)["batches"]) == before + r = c.post(f"/commands/{cid}/retry", {}) + assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA" + + +def test_agent_command_refresh_has_boards(hub, add_project): # #61, Task 6 + _add_project(hub, add_project) + b = hub["client"].post("/batches", {"title": "r", "commands": [{"type": "refresh", "params": {"keys": ["A-1"]}}]}) + cid = hub["client"].get(f"/batches/{b['id']}")["commands"][0]["id"] + got = hub["agent"].get(f"/agent/commands/{cid}") + assert got["params"] == {"keys": ["A-1"], "boards": [{"id": 4216, "type": "scrum"}]} + assert "boards" not in hub["client"].get(f"/batches/{b['id']}")["commands"][0]["params"] + + +def test_board_type_round_trip(hub): # сценарий «Тип доски определён» + c, a = hub["client"], hub["agent"] + c.post("/projects", {"label": "STOCKS_DRRK", "name": "Остатки ДРРК", "board_id": 5120}) + cmd = a.post("/agent/claim", {"agent_version": 12, "settings_version": 1})["command"] # refresh от создания проекта + assert cmd["type"] == "refresh" and {"id": 5120, "type": None} in cmd["params"]["boards"] + a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "result": {"boards": {"5120": "kanban"}}}) + assert [p["board_type"] for p in c.get("/projects")["projects"] if p["label"] == "STOCKS_DRRK"] == ["kanban"] + a.post("/agent/state", {"issues": [{"key": "A-5", "summary": "С", "labels": ["STOCKS_DRRK"]}], "links": []}) + with pytest.raises(HubError) as e: + c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]}) + assert e.value.status == 422 and "канбан-доска" in e.value.detail diff --git a/tests/test_cli.py b/tests/test_cli.py index 8ea8b83..ec6e092 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, release, settings +from jira_hub import cli, paths, projects, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -15,7 +15,12 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): assert cli.main(["init", "--config", str(FIXTURE)]) == 1 assert "уже инициализирован" in capsys.readouterr().err conn = open_db(paths.db_path()) - assert settings.get(conn, "board_id") == 4216 + assert [(x["label"], x["name"], x["board_id"]) for x in projects.list_projects(conn)] == [("AWPCA", "АРМ КА", 4216)] + assert settings.get(conn, "board_id") is None + assert "проект AWPCA «АРМ КА», доска 4216" in out + assert cli.main(["init", "--config", str(FIXTURE), "--force"]) == 0 + capsys.readouterr() + assert [(x["label"], x["board_id"]) for x in projects.list_projects(conn)] == [("AWPCA", 4216)] (tmp_path / "pkg").mkdir() (tmp_path / "pkg" / "tasks.csv").write_text("Код,Статус по факту,Вид работы\nAURORA-1,В работе,UI\n", encoding="utf-8") assert cli.main(["import", str(tmp_path / "pkg")]) == 0 @@ -59,3 +64,12 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): monkeypatch.setattr(os, "replace", real) assert release.load(paths.release_dir())["version"] == 8 assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] + + +def test_mcp_project_option(monkeypatch): + from jira_hub import mcp_server + seen = [] + monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project)) + assert cli.main(["mcp", "--project", "AWPCA"]) == 0 + assert cli.main(["mcp"]) == 0 + assert seen == ["AWPCA", None] diff --git a/tests/test_db.py b/tests/test_db.py index b0ba510..27e8753 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,18 +1,20 @@ +import json import logging import os import sqlite3 import stat import threading -from jira_hub import backup, db, paths, queue, settings +from jira_hub import backup, db, paths, projects, queue, settings def test_connect_creates_schema(open_db): conn = open_db() names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run", - "agent_status"} <= names - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + "agent_status", "project"} <= names + assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols @@ -27,6 +29,8 @@ def _v1_database(path): (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") conn.execute("ALTER TABLE command DROP COLUMN deliveries") + conn.execute("ALTER TABLE batch DROP COLUMN project") + conn.execute("DROP TABLE project") conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'") conn.close() @@ -35,7 +39,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -182,7 +186,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" conn.close() @@ -193,10 +197,12 @@ def test_migrates_v2_with_backup(tmp_path): conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')") conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") conn.execute("DROP TABLE issue_comment") + conn.execute("ALTER TABLE batch DROP COLUMN project") + conn.execute("DROP TABLE project") conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -221,3 +227,66 @@ def test_sqlite_without_unix_vfs_deadlock(): # O-14 (#29): в SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS (исправлена в 3.51.2) вешала хаб под # параллельными запросами — потоки ждали друг друга в sqlite3.connect/close. Python хаба — от uv (pyproject, tool.uv) assert sqlite3.sqlite_version_info[:2] != (3, 51) or sqlite3.sqlite_version_info >= (3, 51, 2), sqlite3.sqlite_version + + +def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=4216): + """База в состоянии схемы v3: без project и batch.project, base_jql/board_id в setting, очередь и журнал на старой JQL.""" + conn = db.connect(path) + settings.ensure_defaults(conn) + for key, value in (("base_jql", base_jql), ("board_id", board_id)): + conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value))) + ts = "2026-10-13T00:00:00+00:00" + for status, jql in (("done", base_jql), ("queued", base_jql), ("claimed", base_jql), ("queued", "key = A-1")): + conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'claude', ?)", (ts,)) + batch = conn.execute("SELECT MAX(id) AS id FROM batch").fetchone()["id"] + conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (?, 1, 'refresh', ?, ?)", + (batch, json.dumps({"jql": jql}), status)) + if status == "done" and jql == base_jql: + cid = conn.execute("SELECT MAX(id) AS id FROM command").fetchone()["id"] + conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, command_id) VALUES (?, ?, 'jql', ?, ?)", + (ts, ts, base_jql, cid)) + conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,)) + conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + conn.execute("ALTER TABLE batch DROP COLUMN project") + conn.execute("DROP TABLE project") + conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v3_creates_awpca(tmp_path): + path = tmp_path / "hub.sqlite" + _v3_database(path) + conn = db.connect(path) + new = 'project = AURORA AND labels in ("AWPCA")' + assert projects.list_projects(conn)[0] | {"created_at": None, "issues_count": None} == { + "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте + assert settings.get(conn, "board_id") == 4216 + assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new + jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")} + assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем + assert jqls["claimed"] == new + queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] + assert sorted(queued) == sorted([new, "key = A-1"]) + assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 + conn.close() + + +def test_migrates_v3_two_labels_board_to_nobody(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels in (AWPCA, STOCKS_DRRK)") + conn = db.connect(tmp_path / "hub.sqlite") + got = {p["label"]: (p["name"], p["board_id"]) for p in projects.list_projects(conn)} + assert got == {"AWPCA": ("АРМ КА", None), "STOCKS_DRRK": ("STOCKS_DRRK", None)} + assert "4216" in caplog.text + conn.close() + + +def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels = AWPCA ORDER BY key") + conn = db.connect(tmp_path / "hub.sqlite") + assert projects.list_projects(conn) == [] + assert "ORDER BY key" in caplog.text + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + conn.close() diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 60c17b8..16db23d 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -7,11 +7,12 @@ from jira_hub.client import HubClient, HubError @pytest.fixture -def live(tmp_path): +def live(tmp_path, add_project): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h.claim_wait = 0 conn = h.conn() settings.set_(conn, "link_types", {"связанные": "ASUPR_Связанные"}) + add_project(conn) tok = settings.tokens(conn) conn.close() srv = api.make_server(h, "127.0.0.1", 0) @@ -28,7 +29,8 @@ def test_create_link_sprint_flow(live): agent, client = live client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [ - {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23", + "labels": ["AWPCA"]}}, {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, {"type": "ensure_sprint", "params": {"key": "new:60", "sprint": "Спринт 2"}}, ]}) @@ -67,7 +69,7 @@ def test_create_link_sprint_flow(live): else: agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) assert [t for t, _ in seen] == ["ensure_issue", "ensure_link", "ensure_sprint", "refresh"] - assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[3][1]["keys"] == ["A-90", "A-1"] + assert seen[1][1]["source"] == "A-90" and seen[2][1]["key"] == "A-90" and seen[2][1]["board_id"] == 4216 and seen[3][1]["keys"] == ["A-90", "A-1"] view = client.get(f"/batches/{bid}") assert view["status"] == "done" and view["remaining_seconds"] == 0 assert all(c["status"] == "done" for c in view["commands"]) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 3568442..dab86e3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -13,10 +13,11 @@ from jira_hub.client import HubClient @pytest.fixture -def live(tmp_path, monkeypatch): +def live(tmp_path, monkeypatch, add_project): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") h.claim_wait = 0 conn = h.conn() + add_project(conn) tok = settings.tokens(conn) conn.close() srv = api.make_server(h, "127.0.0.1", 0) @@ -24,12 +25,14 @@ def live(tmp_path, monkeypatch): 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) - yield {"agent": HubClient(base, tok["agent"])} + monkeypatch.setattr(mcp_server, "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 @@ -167,6 +170,7 @@ def test_async_tools_are_coroutines_and_registered(): 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 @@ -203,7 +207,9 @@ def test_fmt_batch_warns_about_claimed(): def test_batch_create_mentions_labels(): doc = mcp_server.batch_create.__doc__ or "" - assert "labels" in doc and "AWPCA" in doc + assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc + assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc + assert "AWPCA" not in doc def test_batch_create_description_lists_params_from_spec(): @@ -220,7 +226,7 @@ def test_batch_create_description_lists_params_from_spec(): for name, hint in spec.get("hints", {}).items(): assert f"{name} ({hint})" in line, (ctype, name) assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc - assert "labels" in desc and "AWPCA" in desc + assert "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not in desc def test_fmt_agent_update_lines(): @@ -311,6 +317,7 @@ def test_batch_create_bug_with_local_file(live, tmp_path): 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": { "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) @@ -384,3 +391,104 @@ def test_batch_create_description_mentions_path(): 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) + 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 + 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("агент ") # служебные инструменты работают без привязки + + +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() + + +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 + + +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": "т"}}]) + 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") + + +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_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": "с"}}]) + other_id = int(other.split("#")[1].split()[0]) + out = mcp_server.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 — без ограничения + other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"] + out = mcp_server.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) + + +def test_project_create(live, monkeypatch): # сценарий «Проект из чата» + monkeypatch.setattr(mcp_server, "PROJECT", None) + out = mcp_server.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["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") + + +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)] + + +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() + 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) + + +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" diff --git a/tests/test_mirror.py b/tests/test_mirror.py index c638107..b308c81 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -255,3 +255,59 @@ def test_list_issues_hide_closed(conn): assert keys({"hide_closed": "0"}) == ["A-1", "A-2", "A-3", "A-4"] assert keys({"hide_closed": "1"}, []) == ["A-1", "A-2", "A-3", "A-4"] # пустой набор — ничего не скрыто assert keys({"hide_closed": "1", "status": "Готово"}) == [] # отменяет скрытие приложение, не сервер + + +PROJ = "STOCKS_DRRK" + + +def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта» + mirror.apply_state(conn, [ + issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"), + issue("AURORA-2", labels=["AWPCA"], epic_key=None), + issue("AURORA-3", labels=["AWPCA"], epic_key=None), + issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале + issue("AURORA-5", labels=["AWPCA"], epic_key=None), + issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)], + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"}, + {"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"}, + {"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед + {"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале + neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе + + +def test_board_project_scope(conn): + _scene(conn) + snap = mirror.board(conn, BASE, project=PROJ) + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [ + ("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)] + assert {(l["source"], l["target"]) for l in snap["links"]} == { + ("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")} + assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало + + +def test_board_project_last_sync_same(conn): + _scene(conn) + assert mirror.board(conn, BASE, project=PROJ)["last_sync"] == mirror.board(conn, BASE)["last_sync"] + + +def test_list_issues_project(conn): # сценарий «Список проекта» + _scene(conn) + keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})] + assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"] + assert keys(label="kind-dev") == ["AURORA-4"] + assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False + + +def test_list_issues_project_paging_and_filters(conn): + _scene(conn) + page = mirror.list_issues(conn, {"project": PROJ}, limit=2, offset=1) + assert [i["key"] for i in page] == ["AURORA-2", "AURORA-4"] + assert [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, "q": "AURORA-5"})] == ["AURORA-5"] + + +def test_labels_of(conn): + _scene(conn) + assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] + assert mirror.labels_of(conn, "AURORA-404") is None diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..12ce18b --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,148 @@ +import pytest + +from jira_hub import mirror, projects, settings + + +@pytest.fixture +def conn(open_db): + c = open_db() + settings.ensure_defaults(c) + return c + + +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"}) + 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 [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "scrum"] + + +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"}] + + +def test_light_list_same_order_no_count(conn): + projects.create(conn, "B", "b", 1); projects.create(conn, "A", "a") + got = projects.light_list(conn) + assert [p["label"] for p in got] == [p["label"] for p in projects.list_projects(conn)] + assert all("issues_count" not in p and set(p) == {"label", "board_id", "board_type"} for p in got) + + +@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A\nB", "A\\B", "A\x00B", "A\x07B", "A\x7fB", "A,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None]) +def test_bad_label(conn, label): + with pytest.raises(projects.ProjectError) as e: + projects.create(conn, label, "Название") + assert e.value.field == "label" + assert projects.list_projects(conn) == [] + + +@pytest.mark.parametrize("name", ["", " ", "Я" * 101, None, 7]) +def test_bad_name(conn, name): + with pytest.raises(projects.ProjectError) as e: + projects.create(conn, "AWPCA", name) + assert e.value.field == "name" + assert projects.list_projects(conn) == [] + + +@pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5]) +def test_bad_board(conn, board): + with pytest.raises(projects.ProjectError) as e: + projects.create(conn, "AWPCA", "АРМ КА", board) + assert e.value.field == "board_id" + assert projects.list_projects(conn) == [] + + +def test_create_and_list(conn): + a = projects.create(conn, "AWPCA", " АРМ КА ", 4216) + assert a == {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": a["created_at"]} + projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}, {"key": "A-2", "labels": ["awpca", "x"]}], []) + got = projects.list_projects(conn) + assert [(p["label"], p["board_id"], p["issues_count"]) for p in got] == [("AWPCA", 4216, 1), ("STOCKS_DRRK", None, 0)] + + +def test_order_created_then_label(conn): + projects.create(conn, "ZZZ", "z"); projects.create(conn, "AAA", "a"); projects.create(conn, "MMM", "m") + conn.execute("UPDATE project SET created_at = '2026-01-01T00:00:00+00:00' WHERE label IN ('AAA', 'MMM')") + conn.execute("UPDATE project SET created_at = '2026-01-02T00:00:00+00:00' WHERE label = 'ZZZ'") + assert projects.labels(conn) == ["AAA", "MMM", "ZZZ"] + + +def test_same_label_other_case(conn): + projects.create(conn, "AWPCA", "АРМ КА") + with pytest.raises(projects.ProjectExists) as e: + projects.create(conn, "awpca", "…") + assert "уже есть" in str(e.value) and len(projects.list_projects(conn)) == 1 + + +def test_base_jql(conn): + assert 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")' + settings.set_(conn, "project_key", "AUR") + assert 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")' + projects.create(conn, "EMPTY", "Пустой") # зарезервированное слово JQL — в кавычках + assert 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}] + 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 projects.get(conn, "B")["board_type"] is None + + +@pytest.mark.parametrize("jql, labels", [ + ("project = AURORA AND labels = AWPCA", ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, STOCKS_DRRK)", ["AWPCA", "STOCKS_DRRK"]), + ("project = AURORA AND labels IN (A,B)", ["A", "B"]), + ('project = AURORA AND labels = "AWPCA"', ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, awpca)", ["AWPCA"]), + ("project = AURORA", None), + ("project = AURORA AND labels = AWPCA ORDER BY key", None), + ("project = AURORA AND labels in (A B)", None), + ("", None), (None, None), (5, None), +]) +def test_parse_legacy_labels(jql, labels): + assert projects.parse_legacy_labels(jql) == labels + + +def test_add_project_fixture(conn, add_project): + p = add_project(conn) + assert (p["name"], p["board_id"], p["board_type"]) == ("АРМ КА", 4216, "scrum") + + +def test_ensure_legacy(conn): + assert 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 + + +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 + c2 = open_db() + settings.ensure_defaults(c2) + assert 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 aea73c9..9fde9fe 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,7 +2,7 @@ import hashlib import pytest -from jira_hub import db, files, mirror, queue, settings +from jira_hub import db, files, mirror, projects, queue, settings from jira_hub.commands import CommandError from jira_hub.queue import ClaimedError @@ -96,12 +96,14 @@ def test_backup_failure_does_not_block_claim(conn, caplog): assert "не создана" in caplog.text -def test_temp_key_substitution_and_skip(conn): +def test_temp_key_substitution_and_skip(conn, add_project): + add_project(conn) mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") queue.create_batch(conn, "создать", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, - {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая"}}, + {"type": "ensure_issue", "params": {"temp_key": "new:61", "issue_type": "task", "summary": "Вторая", + "labels": ["AWPCA"]}}, {"type": "ensure_sprint", "params": {"key": "new:61", "sprint": "Спринт 2"}}, ]) c1 = queue.claim(conn) @@ -144,7 +146,9 @@ def test_report_after_force_cancel_keeps_cancelled(conn): assert queue.report_result(conn, c["id"], "failed", error="e")["status"] == "cancelled" -def test_reconcile_confirms_by_state(conn): +def test_reconcile_confirms_by_state(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], []) queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}]) c = queue.claim(conn) b = queue.create_batch(conn, "обновить", "claude", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) @@ -156,7 +160,21 @@ def test_reconcile_confirms_by_state(conn): assert queue.batch_view(conn, b["id"])["status"] == "done" -def test_auto_refresh_conditions(conn): +def test_auto_refresh_needs_projects(conn): + queue.claim(conn, agent_version=1, settings_version=1) + assert queue.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) + 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 # агент не на связи queue.claim(conn, agent_version=1, settings_version=1) # отметился, очередь пуста b = queue.maybe_auto_refresh(conn) @@ -228,11 +246,13 @@ def test_reconcile_does_not_confirm_ensure_issue_without_reporter(conn): assert mirror.get_note(conn, "new:1") is not None and mirror.get_note(conn, "A-7") is None -def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch): +def test_reconcile_does_not_overwrite_finished_command(conn, monkeypatch, add_project): """M-1: отчёт агента/отмена между check_confirmed и UPDATE не перезаписываются.""" + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], []) 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": []}], []) + mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "sprint": "Спринт 2", "labels": ["AWPCA"]}], []) real = queue.C.check_confirmed def racing(*a, **kw): @@ -401,8 +421,9 @@ def test_cancelled_batch_with_done_command_is_cancelled(conn): assert view["counts"]["done"] == 2 -def test_empty_full_refresh_keeps_neighbors(conn): - jql = settings.get(conn, "base_jql") +def test_empty_full_refresh_keeps_neighbors(conn, add_project): + add_project(conn) + jql = 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}}]) @@ -420,8 +441,9 @@ def test_cancel_finished_batch_stays_done(conn): assert conn.execute("SELECT cancelled_at FROM batch WHERE id = ?", (b["id"],)).fetchone()["cancelled_at"] is None -def test_redelivered_full_refresh_marks_neighbors_once(conn): - jql = settings.get(conn, "base_jql") +def test_redelivered_full_refresh_marks_neighbors_once(conn, add_project): + add_project(conn) + jql = 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) @@ -592,8 +614,9 @@ def test_attachment_params_rejected(conn, store, params, msg): assert str(e.value) == msg -def test_bug_with_attachment_one_batch(conn, store): +def test_bug_with_attachment_one_batch(conn, store, add_project): """Спека commands «Ошибка с вложением одним пакетом»: вложение выдаётся после ensure_issue с ключом и size.""" + add_project(conn) settings.set_(conn, "issue_types", {"bug": {"name": "Ошибка", "id": None}}) b = queue.create_batch(conn, "ошибка", "claude", [ bug(), {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, attach(key="new:1")], @@ -620,7 +643,7 @@ def test_attachment_size_null_when_file_gone(conn, store): c = queue.claim(conn, files_dir=store) assert c["type"] == "ensure_attachment" and c["params"]["size"] is None assert queue.claim(conn)["params"]["size"] is None # без хранилища — тоже null, не исключение - assert queue.deliver_params(queue.get_command(conn, c["id"]), store)["size"] is None + assert queue.deliver_params(conn, queue.get_command(conn, c["id"]), store)["size"] is None def test_attachment_retry_passes_validation(conn, store): @@ -639,3 +662,282 @@ def test_attachment_not_confirmed_by_state(conn, store): queue.create_batch(conn, "вложение", "claude", [attach()], files_dir=store) c = queue.claim(conn, files_dir=store) assert queue.reconcile(conn) == [] and queue.get_command(conn, c["id"])["status"] == "claimed" + + +# --- пакет проекта (#61, Task 5) ---------------------------------------------- + +S = "STOCKS_DRRK" + + +def two(conn, add_project): + """Проекты AWPCA (доска 4216, scrum) и STOCKS_DRRK (без доски); AURORA-1538 — AWPCA, AURORA-1 — STOCKS_DRRK.""" + add_project(conn) + add_project(conn, S, board_id=None) + mirror.apply_state(conn, [{"key": "AURORA-1538", "summary": "А", "labels": ["AWPCA"]}, + {"key": "AURORA-1", "summary": "С", "labels": [S]}], []) + + +def new_issue(labels=None, temp="new:1"): + p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"} + return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}} + + +def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана» + two(conn, add_project) + src = new_issue(["kind-dev"]) + b = queue.create_batch(conn, "x", "claude", [src], project=S) + view = queue.batch_view(conn, b["id"]) + assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S] + assert src["params"]["labels"] == ["kind-dev"] # входные параметры не мутируются + b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] + + +def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"] + b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P + + +def test_project_ensure_fields_labels_add_case_normalized(conn, add_project): # M-2 + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [fields("AURORA-1", labels_add=["stocks_drrk", "kind-dev", S])], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels_add"] == [S, "kind-dev"] + + +@pytest.mark.parametrize("cmd, msg", [ + (new_issue(["awpca"]), "команда 1: метка другого проекта: awpca"), # «Чужая метка проекта» + (fields("AURORA-1538"), "команда 1: AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта» + (fields("AURORA-1", labels_remove=["stocks_drrk"]), "команда 1: снять метку своего проекта STOCKS_DRRK нельзя"), + (fields("AURORA-1", labels_add=["AWPCA"]), "команда 1: метка другого проекта: AWPCA"), + ({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"), + ({"type": "ensure_status", "params": {"key": "AURORA-1538", "target": "Готово"}}, "AURORA-1538 не в проекте"), + ({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}}, + "команда 1: связь AURORA-1538 → AURORA-404 не касается проекта STOCKS_DRRK"), +]) +def test_project_batch_rejected(conn, add_project, cmd, msg): + two(conn, add_project) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [cmd], project=S) + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_project_batch_accepted(conn, add_project): + two(conn, add_project) + link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}} + queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом» + queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment", + "params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой + queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S) + + +def test_unknown_project_batch(conn): + with pytest.raises(CommandError, match="проекта NOPE нет"): + queue.create_batch(conn, "x", "claude", [fields("AURORA-1")], project="NOPE") + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_batch_without_project_unchanged(conn, add_project): + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["awpca"]), fields("AURORA-1538")]) + view = queue.batch_view(conn, b["id"]) + assert view["project"] is None and view["commands"][0]["params"]["labels"] == ["awpca"] + + +def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта» + two(conn, add_project) + a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S) + queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]] + assert len(queue.list_batches(conn)) == 2 + + +def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды» + two(conn, add_project) + own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA") + loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"] + with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"): + queue.retry_command(conn, cid(own), "claude", project=S) + with pytest.raises(CommandError, match="не из проекта AWPCA"): + queue.retry_command(conn, cid(loose), "claude", project="AWPCA") + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 2 + r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного + assert queue.batch_view(conn, r["id"])["project"] == "AWPCA" + assert queue.batch_view(conn, queue.retry_command(conn, cid(loose), "claude")["id"])["project"] is None + ok = queue.retry_command(conn, cid(own), "claude", project="AWPCA") + assert queue.batch_view(conn, ok["id"])["project"] == "AWPCA" + + +def test_retry_inherits_project_checks(conn, add_project): # Review Focus 5: повтор без project наследует проверки + two(conn, add_project) + own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S) + cid = queue.batch_view(conn, own["id"])["commands"][0]["id"] + conn.execute("UPDATE issue SET labels = '[\"AWPCA\"]' WHERE key = 'AURORA-1'") # задача ушла из проекта + before = conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] + with pytest.raises(CommandError, match="AURORA-1 не в проекте STOCKS_DRRK"): + queue.retry_command(conn, cid, "claude") + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == before + + +# --- доски: ensure_sprint.board_id, refresh.boards, типы досок (#61, Task 6) ----------------------------------------- + +def sprint(key, name="Спринт 2"): + return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}} + + +def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216} + + +@pytest.mark.parametrize("setup, key, msg", [ + (dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"), + (dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"), + (dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"), + (dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале + (dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"), +]) +def test_sprint_board_rejected(conn, add_project, setup, key, msg): + add_project(conn) # AWPCA 4216 scrum + add_project(conn, "STOCKS_DRRK", **setup) + mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]}, + {"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], []) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [sprint(key)]) + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + + +def test_sprint_rejected_message_has_command_number(conn, add_project): + add_project(conn, board_id=5120, board_type="kanban") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + with pytest.raises(CommandError) as e: + queue.create_batch(conn, "x", "claude", [fields("A-1"), sprint("A-1")]) + assert str(e.value) == "команда 2: у проекта AWPCA канбан-доска, спринтов нет" + + +def test_backlog_on_kanban_rejected(conn, add_project): + add_project(conn, board_id=5120, board_type="kanban") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + with pytest.raises(CommandError, match="канбан-доска"): + queue.create_batch(conn, "x", "claude", [sprint("A-1", "backlog")]) + + +def test_sprint_two_projects_same_board(conn, add_project): + add_project(conn) + add_project(conn, "C", board_id=4216) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA", "C"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("A-1")]) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["board_id"] == 4216 + + +def test_sprint_for_new_issue_uses_batch_labels(conn, add_project): + add_project(conn) + add_project(conn, "C", board_id=5120) + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")], + project="AWPCA") # метка проекта дописана Task 5 + assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 4216 + b = queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Н2", "labels": ["C"]}}, + sprint("new:2")]) # без проекта — метки самой команды + assert queue.batch_view(conn, b["id"])["commands"][1]["params"]["board_id"] == 5120 + + +def test_sprint_for_new_issue_without_project_labels_rejected(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "new:1", "labels": ["AWPCA"]}], []) # зеркало не должно подменять метки пакета + with pytest.raises(CommandError, match="не входит ни в один проект"): + queue.create_batch(conn, "x", "claude", [ + {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, sprint("new:1")]) + + +def test_client_board_id_rejected(conn, add_project): + add_project(conn) + with pytest.raises(CommandError, match="лишние параметры board_id"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}]) + + +def test_input_params_not_mutated(conn, add_project): + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + cmds = [sprint("A-1")] + queue.create_batch(conn, "x", "claude", cmds) + assert cmds == [sprint("A-1")] + + +def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + + +def test_retry_premigration_sprint_without_board(conn, add_project): # Review Focus 5 + add_project(conn) + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}], []) + cur = conn.execute("INSERT INTO batch(title, author, created_at, estimate_seconds, project) VALUES ('старый', 'claude', ?, 1, NULL)", + (db.now(),)) + cid = conn.execute("INSERT INTO command(batch_id, seq, type, params) VALUES (?, 1, 'ensure_sprint', ?)", + (cur.lastrowid, '{"key": "A-1", "sprint": "Спринт 2"}')).lastrowid + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"] == {"key": "A-1", "sprint": "Спринт 2", "board_id": 4216} + + +def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 + add_project(conn) + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + add_project(conn, "C", board_id=4216) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}]) + got = queue.claim(conn) + assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + assert queue.claim(conn)["params"]["boards"] == got["params"]["boards"] # довыдача — тоже с boards + + +def test_refresh_boards_empty_list(conn, add_project): + add_project(conn, board_id=None, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert queue.claim(conn)["params"]["boards"] == [] + + +@pytest.mark.parametrize("result, expected", [ + ({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int + ({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет + ({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется + ({"boards": {"abc": "scrum"}}, None), + ({"boards": {"5120": "sprint"}}, None), # мусорный тип + ({"boards": ["5120", "kanban"]}, None), # boards не словарь + ({"boards": "kanban"}, None), + (None, None), +]) +def test_refresh_result_board_types(conn, add_project, result, expected): + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result=result) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected + + +def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): # доска в Jira стала канбаном + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type="scrum") + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result={"boards": {"5120": "kanban"}}) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" + + +def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e") + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" diff --git a/tests/test_settings.py b/tests/test_settings.py index cbaa702..6a47edb 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -18,13 +18,26 @@ def conn(open_db): def test_defaults_and_tokens(conn): - assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" assert settings.get(conn, "pacing")["ensure_fields"] == 120 tok = settings.tokens(conn) assert len(tok["agent"]) > 20 and tok["agent"] != tok["client"] assert "tokens" not in settings.get_all(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.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 settings.for_agent(conn)["board_id"] is None + + +def test_jql_and_board_not_writable(): + with pytest.raises(ValueError, match="собирается из проектов"): + settings.validate("base_jql", "project = AURORA") + with pytest.raises(ValueError, match="свойство проекта"): + settings.validate("board_id", 1) + + def test_set_bumps_version(conn): v0 = settings.get(conn, "settings_version") v1 = settings.set_(conn, "pacing", {"ensure_fields": 5}) @@ -41,10 +54,10 @@ 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) - assert "transitions_map" in keys + 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, "board_id") == 4216 assert settings.get(conn, "pacing") == 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 diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index ca5f2da..fea09f1 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -79,12 +79,12 @@ test("M-3: прокрутка и данные доски после окна к await page.locator(`.board .card[data-key="${key}"] a.open-card`).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(boardLoads).toBe(0); // доска не перезагружалась // ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click"); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click"); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index eca317d..4b1479b 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -16,7 +16,7 @@ test("фильтр → карточка окном → заметка сохра await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page).toHaveURL(/\/app\/issues\?assignee=/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA&assignee=/); await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович"); }); @@ -46,14 +46,14 @@ test("переход по связи и «назад»; ✕ и щелчок по await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click(); await d.getByRole("button", { name: "Закрыть" }).click(); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.mouse.click(5, 300); // фон слева от окна await expect(page.getByRole("dialog")).toHaveCount(0); await page.goBack(); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); @@ -65,7 +65,7 @@ test("прямой адрес и перезагрузка: окно поверх await page.reload(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click(); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); @@ -156,7 +156,7 @@ test("несохранённая заметка: Esc спрашивает, «О await page.keyboard.press("Escape"); await d.getByRole("button", { name: "Уйти" }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); }); test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => { diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts index 5bc9040..d124265 100644 --- a/web/e2e/login.spec.ts +++ b/web/e2e/login.spec.ts @@ -13,5 +13,5 @@ test("неверный токен — пояснение; верный — до test("/ и /app ведут на доску", async ({ page }) => { await login(page); await page.goto("/app"); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); }); diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts new file mode 100644 index 0000000..6a06741 --- /dev/null +++ b/web/e2e/projects.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { agentServeRefresh, login } from "./hub.ts"; + +// проект заводится один раз на тестовый хаб: сценарии идут по порядку, файл — последний по алфавиту +test.describe.serial("проекты", () => { + test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => { + await login(page); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); + await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); + }); + + test("метка занята: окно остаётся открытым, ошибка в alert", async ({ page }) => { + await login(page); + await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" }); + const d = page.getByRole("dialog", { name: "Новый проект" }); + await d.getByLabel("Метка").fill("awpca"); + await d.getByLabel("Название").fill("x"); + await d.getByRole("button", { name: "Создать" }).click(); + await expect(d.getByRole("alert")).toContainText("уже есть"); + await expect(d).toBeVisible(); + }); + + test("создание проекта из формы: адрес, пакет, разбор пакета агентом", async ({ page }) => { + await login(page); + await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" }); + const d = page.getByRole("dialog", { name: "Новый проект" }); + await d.getByLabel("Метка").fill("STOCKS_DRRK"); + await d.getByLabel("Название").fill("Запасы ДРРК"); + await d.getByRole("button", { name: "Создать" }).click(); + await expect(page).toHaveURL(/\?project=STOCKS_DRRK/); + await expect(page.getByRole("status")).toContainText(/пакет #\d+/); + await expect(page.locator(".board .card[data-key]")).toHaveCount(0); + await agentServeRefresh(); // иначе пакет держит «Обновить из Jira» в других файлах на общем тестовом хабе + await expect(page.getByRole("status")).toContainText("выполнен"); + }); + + test("переключение проекта и раздела", async ({ page }) => { + await login(page, "/app/board?project=STOCKS_DRRK"); + await page.getByLabel("Проект", { exact: true }).selectOption({ label: "AWPCA — АРМ КА" }); + await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); + await page.getByRole("link", { name: "Задачи", exact: true }).click(); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); + }); + + test("неизвестный проект в адресе", async ({ page }) => { + await login(page, "/app/issues?project=NOPE"); + await expect(page.getByText("проект NOPE не найден")).toBeVisible(); + }); +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 767d93f..0d66620 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -13,8 +13,15 @@ extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exis extra_issues = extras.get("issues", {}) conn = db.connect(paths.db_path()) settings.ensure_defaults(conn) + +def own_labels(i): + # свои задачи (не соседи) принадлежат проекту AWPCA: метка нужна для разреза GET /board?project=AWPCA + labels = list(i.get("labels") or []) + return labels if i["neighbor"] or "AWPCA" in labels else ["AWPCA", *labels] + + issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} - | extra_issues.get(i["key"], {}) for i in snap["issues"]] + | {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) for i in snap["issues"]: diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1cce5c8..070faf4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; @@ -10,12 +10,14 @@ import { json, mockFetch } from "./test/fetchMock.ts"; const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 0 }; describe("оболочка", () => { - beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); }); + beforeEach(() => { setToken("tok"); mockFetch((_m, url) => json(200, url.pathname === "/projects" ? { projects: [A] } : {})); }); it("/app ведёт на доску", async () => { render(); - expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + // страница сначала показывает заглушку «Загрузка настроек», затем её заменяет другой элемент с тем же testid + await waitFor(() => expect(screen.getByTestId("page-board")).toBeInTheDocument()); }); it("переход «Задачи» и обратно", async () => { @@ -23,9 +25,11 @@ describe("оболочка", () => { render(); await userEvent.click(screen.getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); await userEvent.click(screen.getByRole("link", { name: "Доска" })); expect(router.state.location.pathname).toBe("/app/board"); + expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts index ed8eabc..1d194ac 100644 --- a/web/src/api/queries.test.ts +++ b/web/src/api/queries.test.ts @@ -4,7 +4,7 @@ import { createElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { setToken } from "../auth/token.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; -import { qk, useIssues, useSaveNote } from "./queries.ts"; +import { qk, useBoard, useIssues, useSaveNote } from "./queries.ts"; import type { IssueDetail } from "./types.ts"; const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) => @@ -34,4 +34,16 @@ describe("запросы", () => { await result.current.mutateAsync({} as never); expect(client.getQueryData(qk.issue("A-1"))?.note).toEqual(note); }); + + it("useBoard(project): GET /board?project=; ключ снимка — по проекту, qk.boardAll — общий префикс", async () => { + const seen: (string | null)[] = []; + mockFetch((_m, url) => { seen.push(url.searchParams.get("project")); return json(200, { issues: [], links: [] }); }); + const client = new QueryClient(); + const { result } = renderHook(() => useBoard("AWPCA"), { wrapper: wrap(client) }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(seen).toEqual(["AWPCA"]); + expect(qk.board("AWPCA")).toEqual(["board", "AWPCA"]); + expect(client.getQueryState(qk.board("AWPCA"))).toBeDefined(); + expect(client.getQueryCache().findAll({ queryKey: qk.boardAll })).toHaveLength(1); + }); }); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index c38aa2f..d44bb2b 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,15 +1,17 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client.ts"; -import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; +import type { BoardSnapshot, CreatedProject, HubSettings, IssueDetail, IssueRow, Note, NoteFields, Project } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; -export type IssueFilters = Partial>; +export type IssueFilters = Partial>; export const ISSUE_PAGE = 50; export const qk = { settings: ["settings"] as const, - board: ["board"] as const, + projects: ["projects"] as const, + board: (project: string) => ["board", project] as const, + boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация) issues: (f: IssueFilters) => ["issues", f] as const, issue: (key: string) => ["issue", key] as const, }; @@ -17,7 +19,21 @@ export const qk = { export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); -export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get("/board") }); +export const useBoard = (project: string) => + useQuery({ queryKey: qk.board(project), queryFn: () => api.get("/board", { project }) }); + +export const useProjects = () => + useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) }); + +// «Новый проект»: пакет обновления хаб ставит сам (batch_id), автор — viewer, чтобы доска подхватила его ход +export function useCreateProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (f: { label: string; name: string; board_id?: number }) => + api.post("/projects", { ...f, author: "viewer" }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), // ждём список: onCreated уже видит новый проект + }); +} export const useIssue = (key: string) => useQuery({ queryKey: qk.issue(key), queryFn: () => api.get(`/issues/${encodeURIComponent(key)}`) }); @@ -41,7 +57,7 @@ export function useSaveNote(key: string) { onSuccess: (note) => { qc.setQueryData(qk.issue(key), (d) => (d ? { ...d, note } : d)); void qc.invalidateQueries({ queryKey: ["issues"] }); - void qc.invalidateQueries({ queryKey: qk.board }); + void qc.invalidateQueries({ queryKey: qk.boardAll }); }, }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 746574c..5a7fda7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -56,7 +56,14 @@ export interface BatchCommand { } export interface Batch { id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus; - counts: Record; remaining_seconds: number; commands?: BatchCommand[]; + counts: Record; remaining_seconds: number; commands?: BatchCommand[]; project?: string | null; } export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } export interface CreatedBatch { id: number; estimate_seconds: number; count: number } + +// проект-метка (GET/POST /projects): board_type хаб определяет по отчёту агента, до него — null +export interface Project { + label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; + issues_count: number; +} +export interface CreatedProject { project: Project; batch_id: number } diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index a4ffa71..edec09a 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -9,7 +9,7 @@ import { clearToken, getAuth, setToken } from "./token.ts"; const app =

страница приложения

; -function Page() { useBoard(); return

страница приложения

; } +function Page() { useBoard("AWPCA"); return

страница приложения

; } describe("вход", () => { beforeEach(() => clearToken()); @@ -76,7 +76,7 @@ describe("вход", () => { const { client } = renderWithProviders(); act(() => setToken("new-token")); release(json(401, { error: "unauthorized" })); - await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error")); // 401 точно обработан + await waitFor(() => expect(client.getQueryState(qk.board("AWPCA"))?.status).toBe("error")); // 401 точно обработан expect(screen.getByText("страница приложения")).toBeInTheDocument(); expect(getAuth()).toEqual({ token: "new-token", reason: "none" }); }); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index 9ef0da5..afa9e82 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -26,7 +26,12 @@ function hub(over: Record Response> = {}) { return json(404, { error: "not_found" }); }); } -const page = () => renderWithProviders(, { route: "/board", path: "/board" }); +const page = (project = "AWPCA") => renderWithProviders(, { route: "/board", path: "/board" }); +// канбан-проект: спринтов нет ни у одной своей задачи (соседи — из другого проекта — могут иметь) +const withSprints = (keep: (key: string) => boolean) => ({ ...SNAP, issues: SNAP.issues.map((i: { key: string; sprint: string | null }) => ({ ...i, sprint: keep(i.key) ? i.sprint : null })) }); +const KANBAN = withSprints(() => false); +const NEIGHBOR_ONLY = withSprints((k) => k === "AURORA-9007"); +const NO_SPRINT_TEXT = "колонки «Спринт» нет — показаны все задачи"; describe("страница доски", () => { beforeEach(() => setToken("tok")); @@ -39,6 +44,14 @@ describe("страница доски", () => { expect(screen.getByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); }); + it("снимок запрашивается по проекту страницы", async () => { + const fetch = hub(); + page(); + await screen.findByText(/^Хаб · данные на/); + const urls = fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/board"); + expect(urls[0].searchParams.get("project")).toBe("AWPCA"); + }); + it("пустые словари в настройках — сообщение, а не пустая доска", async () => { hub({ "GET /settings": () => json(200, { kind_labels: {}, link_types: {} }) }); page(); @@ -144,7 +157,7 @@ describe("страница доски", () => { it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => { hub(); - renderWithProviders(, { route: "/board", path: "/board" }); + renderWithProviders(, { route: "/board", path: "/board" }); await screen.findByText(/^Хаб · данные на/); const set = vi.spyOn(Storage.prototype, "setItem"); fireEvent.click(screen.getByRole("button", { name: /критический путь/ })); @@ -158,4 +171,39 @@ describe("страница доски", () => { expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument(); expect(screen.queryByRole("listbox")).toBeNull(); }); + + it("T9: старая раскладка переносится только в проект AWPCA", async () => { + hub(); + localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}'); + const r = page("STOCKS_DRRK"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull(); + expect(localStorage.getItem("graph-pos:board")).toBeNull(); + r.unmount(); + page("AWPCA"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}'); + }); + + it("T9: канбан-проект — без спринтов в снимке: надпись вместо фильтра спринтов", async () => { + hub({ "GET /board": () => json(200, KANBAN) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + }); + + it("T9: канбан-проект, спринт только у соседа — надпись, карточка соседа на доске", async () => { + hub({ "GET /board": () => json(200, NEIGHBOR_ONLY) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + expect(screen.getAllByText("AURORA-9007").length).toBeGreaterThan(0); + }); + + it("T9: проект AWPCA с фикстурой hub-basic — выбор спринта есть", async () => { + hub(); + page("AWPCA"); + expect(await screen.findByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); + expect(screen.queryByText(NO_SPRINT_TEXT)).toBeNull(); + }); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index ebeddb9..8ad3c78 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -16,12 +16,12 @@ import { useBoardPrefs } from "./useBoardPrefs.ts"; import { useBoardSource } from "./useBoardSource.ts"; import "./board.css"; -export function BoardPage() { +export function BoardPage({ project }: { project: string }) { const settings = useSettings(); const closedStatuses = useClosedStatuses(); const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); - const src = useBoardSource(d && !d.missing.length ? d.dicts : null); - const prefs = useBoardPrefs(src.model); + const src = useBoardSource(d && !d.missing.length ? d.dicts : null, project); + const prefs = useBoardPrefs(src.model, project); const today = todayIso(); const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today), [src.model, prefs.filters, prefs.store.laneOrder, today]); diff --git a/web/src/board/model/fromHub.ts b/web/src/board/model/fromHub.ts index 9c1b442..6adee88 100644 --- a/web/src/board/model/fromHub.ts +++ b/web/src/board/model/fromHub.ts @@ -26,5 +26,7 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { }); const missing = addStubs(nodes, links, NOT_IN_HUB); const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; - return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB }; + // фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым» + const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint); + return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB }; } diff --git a/web/src/board/model/model.test.ts b/web/src/board/model/model.test.ts index 0842028..a6c1588 100644 --- a/web/src/board/model/model.test.ts +++ b/web/src/board/model/model.test.ts @@ -21,7 +21,16 @@ describe("модель доски = links.html@3931a88", () => { }); it("fromHub — пустое зеркало без полного прогона", () => { const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot; - expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model); + // эталон написан, когда fromHub всегда давал hasSprint: true; без своих спринтов фильтр спринтов не показывается + expect(canon(fromHub(snap, dicts))).toEqual({ ...golden("hub-empty.expected.json").model, hasSprint: false }); + }); + it("T9: fromHub — спринта у своих задач нет (есть только у соседа) → hasSprint false", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + const none = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: null })) }; + expect(fromHub(none, dicts).hasSprint).toBe(false); + const neighborOnly = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.neighbor ? i.sprint : null })) }; + expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); + expect(fromHub(snap, dicts).hasSprint).toBe(true); }); it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { const text = fx("jira-export.csv"); diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts index 0c926be..a8bbc98 100644 --- a/web/src/board/storage.test.ts +++ b/web/src/board/storage.test.ts @@ -11,7 +11,7 @@ import storageCases from "./__fixtures__/storage.cases.json"; import { computeLayout, measuredHeight } from "./layout/layout.ts"; import { fromHub } from "./model/fromHub.ts"; import { fromJson } from "./model/fromJson.ts"; -import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, toggleLane, writeFlag } from "./storage.ts"; +import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, setStoreProject, toggleLane, writeFlag } from "./storage.ts"; import type { BoardModel, JsonImport } from "./types.ts"; import { noFilters } from "./types.ts"; @@ -126,3 +126,31 @@ describe("операции хранилища", () => { expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow(); }); }); + +describe("ключи по проектам", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => setStoreProject(null)); + + it("AWPCA — прежние ключи", () => { + setStoreProject("AWPCA"); + writeFlag("crit", true); toggleLane("Эпик 1"); + expect(localStorage.getItem("graph-crit")).toBe("1"); + expect(localStorage.getItem("graph-fold:board")).toBe('["Эпик 1"]'); + }); + it("другой проект — ключи с суффиксом, AWPCA не тронут", () => { + localStorage.setItem("graph-pos:board", '{"AURORA-1":{"x":1,"ry":2}}'); + setStoreProject("STOCKS_DRRK"); + writeFlag("crit", true); toggleLane("Эпик 1"); setColWidth("2026-10-20", 400); + saveLayout({ "AURORA-2": { x: 10, ry: 20, w: 200, h: 80, auto: false } }, {}, false); + applyJsonSettings({ layout: { "AURORA-3": { x: 1, ry: 1 } }, layoutByDate: {}, laneOrder: ["A"], filters: { neighbors: true } } as unknown as JsonImport); + expect(localStorage.getItem("graph-crit@STOCKS_DRRK")).toBe("1"); + expect(localStorage.getItem("graph-fold:board@STOCKS_DRRK")).toBe('["Эпик 1"]'); + expect(localStorage.getItem("graph-lanes:board@STOCKS_DRRK")).toBe('["A"]'); + expect(localStorage.getItem("graph-neighbors@STOCKS_DRRK")).toBe("1"); + expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-1":{"x":1,"ry":2}}'); + expect(localStorage.getItem("graph-crit")).toBeNull(); + expect(laneOrder()).toEqual(["A"]); + setStoreProject("AWPCA"); + expect(laneOrder()).toEqual([]); + }); +}); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts index fbd132d..f0d5518 100644 --- a/web/src/board/storage.ts +++ b/web/src/board/storage.ts @@ -1,6 +1,8 @@ // хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать) // localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени // файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища +// ключи по проектам (решения 10 и 20): раскладка и флаги доски — отдельно для каждого проекта; у AWPCA ключи прежние, у остальных — суффикс «@<метка>» +// (graph-pos:board@STOCKS_DRRK). Все функции модуля берут ключи через pref()/fileKey() и текущий набор (setStoreProject) import { COLW, GRID } from "./constants.ts"; import type { RelBox, Layout } from "./layout/layout.ts"; import type { Filters, JsonImport } from "./types.ts"; @@ -12,13 +14,19 @@ export const store = { del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } }, json(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } }, }; -export const pref = (name: string): string => "graph-" + name; +export const LEGACY_PROJECT = "AWPCA"; // у него ключи без суффикса — раскладка, сделанная до проектов, сохраняется +export function storeSuffix(label: string | null): string { return label === null || label === LEGACY_PROJECT ? "" : "@" + label; } +let suffix = ""; // набор ключей текущего проекта +export function setStoreProject(label: string | null): void { suffix = storeSuffix(label); } +export const isLegacyStore = (): boolean => suffix === ""; +export const pref = (name: string): string => "graph-" + name + suffix; const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"]; -export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD; +export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD + suffix; export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v); +// миграции — только для набора AWPCA (isLegacyStore): у остальных проектов старых ключей не было // до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии // переносим на общий ключ набор того файла, где расставлено больше всего карточек export function migrateStore(): void { diff --git a/web/src/board/useBoardPrefs.ts b/web/src/board/useBoardPrefs.ts index a97628f..13ccc73 100644 --- a/web/src/board/useBoardPrefs.ts +++ b/web/src/board/useBoardPrefs.ts @@ -23,8 +23,12 @@ const pick = (model: BoardModel, f: Filters) => ({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) }); // настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка -export function useBoardPrefs(model: BoardModel): BoardPrefsApi { - const [prefs, setPrefs] = useState(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); }); +export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi { + const [prefs, setPrefs] = useState(() => { + st.setStoreProject(project); // ключи проекта — до любого чтения; страница пересоздаётся при смене проекта (key={project}) + if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); } + return st.readPrefs(); + }); const [store, setStore] = useState(() => readStore(0)); const [sel, setSel] = useState(() => pick(model, prefs.filters)); if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx index 3d102d8..73ec91e 100644 --- a/web/src/board/useBoardSource.test.tsx +++ b/web/src/board/useBoardSource.test.tsx @@ -16,7 +16,7 @@ const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures_ const SNAP = JSON.parse(fx("hub-basic.board.json")); const CSV = fx("jira-export.csv"); const { dicts } = dictsFromSettings(settings); -const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }), +const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, "AWPCA", { pollMs: 10 }), { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; @@ -65,7 +65,7 @@ describe("useBoardSource", () => { act(() => { result.current.loadFile("jira-export.csv", CSV); }); expect(result.current.source).toBe("file"); slow.resolve(json(200, SNAP)); - await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу + await waitFor(() => expect(client.getQueryData(qk.board("AWPCA"))).toBeDefined()); // ответ хаба точно применён к кэшу expect(result.current.source).toBe("file"); expect(result.current.label).toBe("jira-export.csv (из файла)"); act(() => result.current.reload()); diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts index c0654cf..2ca77d6 100644 --- a/web/src/board/useBoardSource.ts +++ b/web/src/board/useBoardSource.ts @@ -30,9 +30,9 @@ export interface BoardSource { const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); -export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource { +export function useBoardSource(dicts: Dicts | null, project: string, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource { const qc = useQueryClient(); - const board = useBoard(); + const board = useBoard(project); const [source, setSource] = useState({ kind: "hub" }); const [event, setEvent] = useState(null); const [active, setActive] = useState(null); @@ -111,8 +111,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll setActive(null); qc.removeQueries({ queryKey: ["batch", id] }); skipPickup.current = true; - void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1) - }, [qc]); + void qc.invalidateQueries({ queryKey: qk.board(project) }); // снимок обновится; показанный файл не заменяется (M-1) + }, [qc, project]); useEffect(() => { if (!active) return; @@ -165,8 +165,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll const reload = useCallback(() => { setSource({ kind: "hub" }); setEvent((cur) => (cur?.kind === "batch" ? cur : null)); - void qc.refetchQueries({ queryKey: qk.board }); - }, [qc]); + void qc.refetchQueries({ queryKey: qk.board(project) }); + }, [qc, project]); const loadFile = useCallback((fileName: string, text: string): JsonImport | null => { if (!dicts) return null; @@ -181,7 +181,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба setSource({ kind: "hub" }); setEvent({ kind: "json-applied", fileName }); - void qc.refetchQueries({ queryKey: qk.board }); + void qc.refetchQueries({ queryKey: qk.board(project) }); } else { // хаб не ответил — снимок из файла setSource({ kind: "file", model: r.model }); setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true }); @@ -191,7 +191,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) }); return null; } - }, [dicts, board.data, qc]); + }, [dicts, board.data, qc, project]); const ls = board.data?.last_sync ?? null; // заглушка без данных — один и тот же объект между рендерами (от identity модели зависит выбор спринтов на странице) diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 4a07f01..7ff09eb 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -15,6 +15,7 @@ const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, repo function hub() { return mockFetch((_m, url) => { const p = url.pathname; + if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); if (p === "/settings") return json(200, settings); if (p === "/board") return json(200, board); if (p === "/issues") return json(200, { issues: rows }); diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 32c03dd..76d885a 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -30,9 +30,10 @@ describe("список задач", () => { it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); const q = issuesCalls(fetch)[0].searchParams; + expect(q.get("project")).toBe("AWPCA"); expect(q.get("sprint")).toBe("AWPCA_Спринт 2"); expect(q.get("neighbor")).toBe("1"); expect(q.get("limit")).toBe("50"); @@ -41,7 +42,7 @@ describe("список задач", () => { it("выбор исполнителя пишет его в адрес и в запрос", async () => { const fetch = hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Петров Пётр Петрович"); await waitFor(() => expect(router.state.location.search).toContain("assignee=")); @@ -50,7 +51,7 @@ describe("список задач", () => { it("«Показать ещё» до неполной порции", async () => { const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51)))); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); await userEvent.click(await screen.findByRole("button", { name: "Показать ещё" })); await waitFor(() => expect(screen.getAllByRole("row")).toHaveLength(1 + 67)); expect(screen.queryByRole("button", { name: "Показать ещё" })).toBeNull(); @@ -59,7 +60,7 @@ describe("список задач", () => { it("поиск — один запрос после паузы ввода", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const before = issuesCalls(fetch).length; await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); @@ -75,7 +76,7 @@ describe("список задач", () => { if (url.pathname === "/board") return json(200, SNAP); return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); down = false; await userEvent.click(screen.getByRole("button", { name: "Повторить" })); @@ -84,13 +85,13 @@ describe("список задач", () => { it("эпик показывается названием из снимка", async () => { hub(() => [row(1)]); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument(); }); it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => { hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues?q=свод", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues?q=свод", path: "/issues" }); const box = await screen.findByLabelText("Поиск по ключу или теме"); expect(box).toHaveValue("свод"); await act(async () => { await router.navigate("/issues"); }); @@ -102,7 +103,7 @@ describe("список задач", () => { it("T13.2: ввод поиска пишется в адрес через replace (шаг «назад» не застревает)", async () => { hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const before = router.state.historyAction; await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); @@ -113,7 +114,7 @@ describe("список задач", () => { it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => { hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); const sel = await screen.findByLabelText("Спринт"); await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument()); expect(sel).toHaveValue("AWPCA_Спринт 0"); @@ -126,13 +127,13 @@ describe("список задач", () => { if (url.pathname === "/board") return board.promise; return json(200, { issues: [row(1)] }); }); - const { client } = renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + const { client } = renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const sel = await screen.findByLabelText("Спринт"); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); - expect(client.getQueryData(qk.board)).toBeUndefined(); + expect(client.getQueryData(qk.board("AWPCA"))).toBeUndefined(); board.resolve(json(200, SNAP)); - await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // снимок реально применён + await waitFor(() => expect(client.getQueryData(qk.board("AWPCA"))).toBeDefined()); // снимок реально применён await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument()); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(sel).toHaveValue("AWPCA_Спринт 2"); @@ -144,7 +145,7 @@ describe("список задач", () => { if (url.pathname === "/board") return new Promise(() => {}); // снимок не приходит до конца теста return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const sel = await screen.findByLabelText("Спринт"); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); @@ -155,7 +156,7 @@ describe("список задач", () => { it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => { const fetch = hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(hideBox()).toBeChecked(); let q = issuesCalls(fetch).at(-1)!.searchParams; @@ -173,7 +174,7 @@ describe("список задач", () => { it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); + renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(hideBox()).not.toBeChecked(); expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true); @@ -181,7 +182,7 @@ describe("список задач", () => { it("выбран закрытый статус Jira — hide_closed не отправляется", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const q = issuesCalls(fetch).at(-1)!.searchParams; expect(q.get("status")).toBe("Готово"); @@ -191,7 +192,7 @@ describe("список задач", () => { it("выбран незакрытый статус Jira — hide_closed отправляется", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1"); }); @@ -203,7 +204,7 @@ describe("список задач", () => { if (url.pathname === "/board") return json(200, SNAP); return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); st.resolve(json(200, settings)); await screen.findByRole("link", { name: "AURORA-9001" }); const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues"); @@ -212,7 +213,7 @@ describe("список задач", () => { it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); + renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(screen.queryByLabelText("Статус по факту")).toBeNull(); expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull(); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 7157572..4419b92 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -12,7 +12,7 @@ const SELECTS = [ ] as const; const Q_DELAY = 300; // поиск — после паузы ввода (R-7) -export function IssuesPage() { +export function IssuesPage({ project }: { project: string }) { const [params, setParams] = useSearchParams(); const filters = useMemo(() => Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]); @@ -35,14 +35,14 @@ export function IssuesPage() { return () => clearTimeout(t); }, [q, filters.q, setParams]); - const board = useBoard(), settings = useSettings(); + const board = useBoard(project), settings = useSettings(); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); const names = useMemo(() => epicNames(board.data), [board.data]); // закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет const showClosed = params.get("closed") === "1"; const closed = useClosedStatuses(); - const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }), - [filters, showClosed, closed]); + const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }), + [filters, project, showClosed, closed]); const list = useIssues(query); const rows = list.data?.pages.flat() ?? []; diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx new file mode 100644 index 0000000..5d4d176 --- /dev/null +++ b/web/src/projects/NewProjectDialog.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { ApiError } from "../api/client.ts"; +import { useCreateProject } from "../api/queries.ts"; +import type { Project } from "../api/types.ts"; +import "./projects.css"; + +// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать» +export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void }) { + const ref = useRef(null); + const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState(""); + const create = useCreateProject(); + useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const f: { label: string; name: string; board_id?: number } = { label: label.trim(), name: name.trim() }; + if (board.trim() !== "") f.board_id = Number(board); + create.mutate(f, { onSuccess: (r) => onCreated(r.project) }); + }; + const err = create.error; + const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null; + + return ( + { e.preventDefault(); if (!create.isPending) onClose(); }}> +
+

Новый проект

+ + + +
+ + + {text && {text}} +
+
+
+ ); +} diff --git a/web/src/projects/ProjectContext.ts b/web/src/projects/ProjectContext.ts new file mode 100644 index 0000000..9fbd134 --- /dev/null +++ b/web/src/projects/ProjectContext.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from "react"; +import type { Project } from "../api/types.ts"; +import type { Resolution } from "./current.ts"; + +export type ProjectState = { kind: "loading" } | { kind: "error"; error: Error } | Resolution; + +export interface ProjectCtx { + state: ProjectState; + projects: Project[]; + select(label: string): void; + openNew(): void; +} + +export const ProjectContext = createContext(null); + +export function useProjectContext(): ProjectCtx { + const c = useContext(ProjectContext); + if (!c) throw new Error("ProjectContext: вне оболочки приложения"); + return c; +} diff --git a/web/src/projects/ProjectGate.tsx b/web/src/projects/ProjectGate.tsx new file mode 100644 index 0000000..3f6eb87 --- /dev/null +++ b/web/src/projects/ProjectGate.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import type { Project } from "../api/types.ts"; +import { useProjectContext } from "./ProjectContext.ts"; + +// страница доски/списка показывается, только когда текущий проект определён; иначе — причина и что делать +export function ProjectGate({ children }: { children: (p: Project) => ReactNode }) { + const { state, openNew } = useProjectContext(); + switch (state.kind) { + case "loading": return

Загрузка проектов…

; + case "error": return

Проекты не загрузились: {state.error.message}

; + case "unknown": + return

проект {state.label} не найден

Выберите проект в списке «Проект» вверху.

; + case "none": + return

Проектов нет.

+
; + case "ok": return <>{children(state.project)}; + } +} diff --git a/web/src/projects/ProjectPicker.tsx b/web/src/projects/ProjectPicker.tsx new file mode 100644 index 0000000..3f8e1b3 --- /dev/null +++ b/web/src/projects/ProjectPicker.tsx @@ -0,0 +1,16 @@ +import { useProjectContext } from "./ProjectContext.ts"; + +const NEW = " new"; // служебное значение пункта «Новый проект…»: метка пробелов содержать не может + +export function ProjectPicker() { + const { state, projects, select, openNew } = useProjectContext(); + const current = state.kind === "ok" ? state.project.label : ""; + return ( + + ); +} diff --git a/web/src/projects/current.test.ts b/web/src/projects/current.test.ts new file mode 100644 index 0000000..f7b0fe6 --- /dev/null +++ b/web/src/projects/current.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Project } from "../api/types.ts"; +import { rememberedProject, rememberProject, REMEMBER_KEY, resolveProject, withProject } from "./current.ts"; + +const A = { label: "AWPCA", name: "АРМ КА" } as Project, S = { label: "STOCKS_DRRK", name: "Запасы ДРРК" } as Project; + +describe("текущий проект", () => { + afterEach(() => vi.restoreAllMocks()); + + it("адрес, затем запомненный, затем первый", () => { + expect(resolveProject("STOCKS_DRRK", "AWPCA", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: true }); + expect(resolveProject(null, "STOCKS_DRRK", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: false }); + expect(resolveProject(null, "GONE", [A, S])).toEqual({ kind: "ok", project: A, fromUrl: false }); + expect(resolveProject("NOPE", "AWPCA", [A, S])).toEqual({ kind: "unknown", label: "NOPE" }); + expect(resolveProject(null, null, [])).toEqual({ kind: "none" }); + }); + + it("withProject сохраняет фильтры", () => { + expect(withProject("?sprint=С2&project=AWPCA", "STOCKS_DRRK")).toBe("?sprint=%D0%A12&project=STOCKS_DRRK"); + expect(withProject("", "AWPCA")).toBe("?project=AWPCA"); + }); + + it("запоминание проекта в localStorage", () => { + rememberProject("STOCKS_DRRK"); + expect(localStorage.getItem(REMEMBER_KEY)).toBe("STOCKS_DRRK"); + expect(rememberedProject()).toBe("STOCKS_DRRK"); + }); + + it("память выбора переживает недоступное хранилище", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("закрыто"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("закрыто"); }); + expect(rememberedProject()).toBeNull(); + expect(() => rememberProject("AWPCA")).not.toThrow(); + }); +}); diff --git a/web/src/projects/current.ts b/web/src/projects/current.ts new file mode 100644 index 0000000..d5f4390 --- /dev/null +++ b/web/src/projects/current.ts @@ -0,0 +1,35 @@ +import type { Project } from "../api/types.ts"; + +// выбор проекта в этом браузере; ключ один на все проекты (спека web-app «Проект в приложении») +export const REMEMBER_KEY = "hub-project"; + +export type Resolution = + | { kind: "ok"; project: Project; fromUrl: boolean } + | { kind: "unknown"; label: string } + | { kind: "none" }; + +// адрес → запомненный (если есть в списке) → первый; метка из адреса, которой нет в списке, — unknown, а не подмена +export function resolveProject(urlLabel: string | null, remembered: string | null, projects: Project[]): Resolution { + if (urlLabel) { + const p = projects.find((x) => x.label === urlLabel); + return p ? { kind: "ok", project: p, fromUrl: true } : { kind: "unknown", label: urlLabel }; + } + if (!projects.length) return { kind: "none" }; + const p = (remembered && projects.find((x) => x.label === remembered)) || projects[0]; + return { kind: "ok", project: p, fromUrl: false }; +} + +export function rememberedProject(): string | null { + try { return localStorage.getItem(REMEMBER_KEY); } catch { return null; } +} + +export function rememberProject(label: string): void { + try { localStorage.setItem(REMEMBER_KEY, label); } catch { /* хранилище недоступно: выбор живёт только в адресе */ } +} + +// query-строка с project; остальные параметры сохраняются +export function withProject(search: string, label: string): string { + const p = new URLSearchParams(search); + p.set("project", label); + return "?" + p.toString(); +} diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css new file mode 100644 index 0000000..54c61a5 --- /dev/null +++ b/web/src/projects/projects.css @@ -0,0 +1,10 @@ +/* выбор проекта и окно «Новый проект» */ +.appbar .project-picker{font:inherit;font-size:14px;height:30px;max-width:280px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} +.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0} +.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px} +.project-dialog::backdrop{background:rgba(0,0,0,.4)} +.project-dialog form{display:flex;flex-direction:column;gap:10px} +.project-dialog h2{margin:0 0 4px;font-size:18px} +.project-dialog label{display:flex;flex-direction:column;gap:2px;font-size:13px;color:var(--muted)} +.project-dialog input{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} +.project-dialog .actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px} diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx new file mode 100644 index 0000000..9214398 --- /dev/null +++ b/web/src/projects/projects.test.tsx @@ -0,0 +1,188 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../App.tsx"; +import { setToken } from "../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../routes.tsx"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { issue } from "../test/issueFixtures.ts"; +import { REMEMBER_KEY } from "./current.ts"; + +const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "2026-10-01T00:00:00+00:00", issues_count: 9 }; +const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, board_type: null, created_at: "2026-10-02T00:00:00+00:00", issues_count: 0 }; +const ROW = { ...SNAP.issues[1], fetched_at: "" }; + +interface Call { method: string; url: URL; body: unknown } +function hub(projects: unknown[] = [A, S], over: Record Response | Promise> = {}) { + const calls: Call[] = []; + const fetch = mockFetch((method, url, body) => { + calls.push({ method, url, body }); + const k = `${method} ${url.pathname}`; + if (over[k]) return over[k](body); + if (k === "GET /projects") return json(200, { projects }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /board") return json(200, SNAP); + if (k === "GET /issues") return json(200, { issues: [ROW] }); + if (k === "GET /issues/AURORA-9001") return json(200, issue("AURORA-9001")); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agent") return json(200, { online: true }); + return json(404, { error: "not_found" }); + }); + const get = (path: string) => calls.filter((c) => c.method === "GET" && c.url.pathname === path); + return { fetch, calls, get }; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; + +describe("проект в приложении", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => { + const h = hub(); + const router = open("/app/board"); + await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); + expect(router.state.historyAction).toBe("REPLACE"); + await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); + expect(h.get("/board")[0].url.searchParams.get("project")).toBe("AWPCA"); + expect(localStorage.getItem(REMEMBER_KEY)).toBe("AWPCA"); + }); + + it("переключение проекта: доска, ссылка «Задачи», список и варианты фильтров", async () => { + const h = hub(); + const router = open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + expect(router.state.historyAction).toBe("PUSH"); + await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); + const link = screen.getByRole("link", { name: "Задачи" }); + expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK"); + await userEvent.click(link); + expect(router.state.location.pathname + router.state.location.search).toBe("/app/issues?project=STOCKS_DRRK"); + await screen.findByTestId("page-issues"); + await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); + expect(h.get("/board").filter((c) => c.url.searchParams.get("project") === "STOCKS_DRRK").length).toBeGreaterThan(0); + }); + + it("запомненный проект подставляется в адрес", async () => { + localStorage.setItem(REMEMBER_KEY, "STOCKS_DRRK"); + hub(); + const router = open("/app/issues"); + await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + expect(router.state.historyAction).toBe("REPLACE"); + }); + + it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => { + const h = hub(); + open("/app/issues?project=NOPE"); + expect(await screen.findByText(/проект NOPE не найден/)).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Проект" })).toBeInTheDocument(); + expect(h.get("/issues")).toHaveLength(0); + }); + + it("проектов нет: приглашение создать, без GET /board; щелчок открывает окно", async () => { + const h = hub([]); + open("/app/board"); + await userEvent.click(await screen.findByRole("button", { name: "+ Новый проект…" })); + expect(screen.getByRole("dialog", { name: "Новый проект" })).toHaveAttribute("open"); + expect(h.get("/board")).toHaveLength(0); + }); + + it("создание проекта из формы", async () => { + const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) }); + const router = open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + const post = h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!; + expect(post.body).toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer" }); + expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument(); + }); + + it("с номером доски board_id уходит числом", async () => { + const h = hub([A], { "POST /projects": () => json(201, { project: S, batch_id: 7 }) }); + open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.type(within(dlg).getByLabelText("Номер доски Jira"), "5120"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + await waitFor(() => expect(h.calls.some((c) => c.method === "POST" && c.url.pathname === "/projects")).toBe(true)); + expect(h.calls.find((c) => c.method === "POST" && c.url.pathname === "/projects")!.body) + .toEqual({ label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer" }); + }); + + it("метка занята: окно открыто, текст ошибки хаба рядом с «Создать»", async () => { + hub([A], { "POST /projects": () => json(409, { error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA" }) }); + open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "awpca"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Дубль"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + expect(await within(dlg).findByRole("alert")).toHaveTextContent("label: проект с такой меткой уже есть: AWPCA"); + expect(dlg).toHaveAttribute("open"); + }); + + it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => { + let n = 0; + const h = hub([A, S], { + "GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })), + "POST /projects": () => json(201, { project: S, batch_id: 7 }), + }); + const router = open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает + await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + expect(h.get("/projects").length).toBeGreaterThan(1); + expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK"); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + }); + + it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } }); + open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled()); + expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled(); + fireEvent(dlg, new Event("cancel", { cancelable: true })); + expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument(); + release(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument()); + }); + + it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => { + const h = hub(); + const router = open("/app/issues/AURORA-9001"); + expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); + await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true)); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.search).toBe(""); + expect(screen.getByTestId("issue-card")).toBeInTheDocument(); + }); +}); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index f096bf8..f9ccdde 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,10 +1,15 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router"; +import { useProjects } from "../api/queries.ts"; import { clearToken } from "../auth/token.ts"; import { CloseAbortContext } from "../issues/closeAbort.ts"; import { IssueModal } from "../issues/IssueModal.tsx"; import { cardState, LIST_BACKGROUND } from "../issues/modal.ts"; +import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; +import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; +import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; +import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import "./shell.css"; @@ -48,14 +53,40 @@ export function Layout() { const abortClose = useCallback(() => { closingDirect.current = false; }, []); const at = pageLocation.pathname; + + // текущий проект: адрес страницы (под окном карточки — адрес списка) → запомненный → первый; карточка от него не зависит + const projects = useProjects(); + const urlLabel = new URLSearchParams(pageLocation.search).get("project"); + const state = useMemo(() => projects.isPending ? { kind: "loading" } + : projects.error && !projects.data ? { kind: "error", error: projects.error } // ошибка фонового рефетча — остаётся последний список + : resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]); + const okLabel = state.kind === "ok" ? state.project.label : null; + const fromUrl = state.kind === "ok" && state.fromUrl; + const onPage = at === "/board" || at === "/issues"; + useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); + // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) + useEffect(() => { + if (okLabel && !fromUrl && issueKey === null && onPage) { + void navigate(at + withProject(pageLocation.search, okLabel), { replace: true }); + } + }, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]); + + const [newOpen, setNewOpen] = useState(false); + const section = at === "/issues" ? "/issues" : "/board"; + const select = useCallback((label: string) => { void navigate({ pathname: section, search: withProject("", label) }); }, [navigate, section]); + const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), + [state, projects.data, select]); + const tab = (to: string, label: string) => { const active = at === to || at.startsWith(to + "/"); - return {label}; + return {label}; }; return ( - <> +
Хаб Jira + @@ -64,6 +95,7 @@ export function Layout() { {page} {issueKey && } - + {newOpen && setNewOpen(false)} onCreated={(p) => { setNewOpen(false); select(p.label); }} />} + ); } diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx index 5111b01..fda044e 100644 --- a/web/src/shell/pages.tsx +++ b/web/src/shell/pages.tsx @@ -1,12 +1,13 @@ import { Navigate, type RouteObject } from "react-router"; import { BoardPage } from "../board/BoardPage.tsx"; import { IssuesPage } from "../issues/IssuesPage.tsx"; +import { ProjectGate } from "../projects/ProjectGate.tsx"; import { NotFound } from "./NotFound.tsx"; // страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10) export const PAGE_ROUTES: RouteObject[] = [ { path: "/", element: }, - { path: "/board", element: }, - { path: "/issues", element: }, + { path: "/board", element: {(p) => } }, + { path: "/issues", element: {(p) => } }, { path: "*", element: }, ];