Несколько проектов-меток в мосте (#61) #62

Merged
aep merged 16 commits from feat/61-projects into main 2026-10-19 22:03:57 +03:00
64 changed files with 4452 additions and 201 deletions

View File

@ -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/<sha256>` (каталог — параметр, `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/<sha256>` → `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` и своих копий не держит.

View File

@ -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/<sha256>`, каталог — параметр `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` — опубликовать версию агента для самообновления (только вверх).
## Тесты

View File

@ -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:<n>`),
`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:<n>`), `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` берёт его из

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-19

View File

@ -0,0 +1,359 @@
## Context
`main` eb485d7. Устройство, на которое опираются правки:
- **Настройки** — `src/jira_hub/settings.py`: `DEFAULTS` содержит `base_jql = "project = AURORA AND labels = AWPCA"`,
`project_key = "AURORA"`, `board_id = None`; `AGENT_KEYS` отдаёт агенту `base_jql` и `board_id`. На живом хабе
`board_id = 4216`, `settings_version = 10`, `schema_version = 3`. Ключи `labels`, `comment_marker` хаб и агент не
читают (наследие `jira-sync`), здесь не трогаются.
- **Полный прогон** — `api` (`POST /refresh {full: true}`) и `queue.maybe_auto_refresh` ставят `refresh {jql: base_jql}`;
`queue.is_full_refresh` (по `params.jql == base_jql`) решает, считать ли загрузку полной (`authoritative` в
`mirror.apply_state` → пересчёт `neighbor`). `mirror.board(conn, base_jql)` ищет `last_sync` по
`sync_run.query = base_jql`.
- **Снимок доски и список** — `mirror.board` отдаёт всё зеркало; `mirror.list_issues` фильтрует по `label` через
`json_each(i.labels)`; признак `neighbor` — колонка зеркала (вне базовой JQL).
- **Выдача команд** — `queue.deliver_params` дописывает параметры при выдаче (сейчас `size` у `ensure_attachment`), в
базе `params` без них.
- **Агент** (`jira-agent/src/agent.py`, v11): `sprints()` читает активные и будущие спринты доски `settings.board_id`;
`sprint_names(keys)` в `refresh` собирает имена спринтов задач по этой доске; `do_ensure_sprint` без `board_id` —
`failed`. Канбан-доска на `GET /board/{id}/sprint` отвечает Jira ошибкой (спринтов у неё нет).
- **MCP** — `mcp_server.py`, user scope, `uv run --project ~/projects/jira-bridge/jira-hub jira-hub mcp`; ходит только
через `client`. Описание `batch_create` требует метку `AWPCA` в `ensure_issue`.
- **Веб-приложение** — `shell/Layout.tsx` (шапка «Хаб Jira», вкладки «Доска», «Задачи»), доска — `board/useBoardSource.ts`
(`GET /board`), список — `issues/IssuesPage.tsx` (`GET /issues`, варианты фильтров из снимка доски). Раскладка доски —
`board/storage.ts`, ключи `localStorage` `graph-<имя>…` от имени источника (у хаба — «Хаб»). `fromHub.ts` ставит
`hasSprint: true` всегда.
## Goals / Non-Goals
**Goals:** несколько проектов-меток в одном мосте; у каждого своя доска Jira (scrum или kanban); сессия Claude и
страница веб-приложения работают в разрезе одного проекта; AWPCA после выкатки работает как раньше.
**Non-Goals и Anti-Goals:** см. proposal.md.
## Decisions
1. **Проект — таблица `project`, схема v4.** Поля: `label` (TEXT PK — метка Jira, она же ключ проекта), `name` (TEXT,
непустое), `board_id` (INTEGER или NULL), `board_type` (`scrum` | `kanban` | NULL — ещё не известен), `created_at`.
Отдельная таблица, а не ключ настроек: проект — сущность со своим жизненным циклом, а настройки уходят агенту целиком
и поднимают `settings_version` при каждой правке. Создание проекта `settings_version` не поднимает: агенту проекты не
нужны, доски он получает в параметрах команд (решение 7).
2. **Метка проекта** — непустая строка без пробельных и управляющих символов (категория Unicode `Cc`), запятых,
кавычек, скобок и обратной косой черты (метка Jira не может содержать пробел; остальное исключено, чтобы метку можно
было вставить в JQL в двойных кавычках без экранирования), до 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает
метки без учёта регистра в JQL). Название — непустое после обрезки пробелов, до 100 символов. `board_id` — целое > 0
или отсутствует.
3. **Базовая JQL собирается, а не хранится.** `base_jql = 'project = <project_key> AND labels in ("<метка>", …)'` (каждая метка в двойных
кавычках, чтобы `EMPTY`, `null` и прочие зарезервированные слова JQL были значением; метки по created_at, затем
label); функция в `settings` (или новом модуле `projects`), её используют `POST /refresh
{full}`, автообновление, `is_full_refresh`, `mirror.board` и `GET /settings` (поле `base_jql` в ответе — вычисленное,
только для чтения; `PUT /settings` с `base_jql` или `board_id` — 422 с указанием, что JQL собирается из проектов, а
доска — свойство проекта). Без проектов `base_jql = null`: `POST /refresh {full}` — 422 «нет проектов», автообновление
не ставится. Решение владельца: одна JQL на все проекты.
4. **Миграция v3 → v4** (в одной транзакции с копией базы, как прежние): создать `project` и колонку `batch.project`;
если `base_jql` в настройках имеет вид `… labels = <M>` или `… labels in (<M1>, …)` — завести проекты с этими
метками; название проекта `AWPCA` — «АРМ КА», остальных — сама метка; `board_id` из настроек достаётся проекту, если
проект один, иначе никому (с предупреждением в лог); `board_type = NULL` (определит первый `refresh`). Строкам
`sync_run` с `query` = старая `base_jql` записать `query` = новая вычисленная — иначе доска до первого полного
прогона показывала бы «полного прогона не было»; так же переписать `params.jql` команд `refresh` в `queued` и
`claimed`, равный старой `base_jql`, — иначе такой прогон не признается полным. Ключи `base_jql` и `board_id` в
`setting` миграция **не удаляет**: базу открывает любой процесс новой версии (`HubClient.from_home` в MCP, CLI), и
это может случиться после `git pull`, но до перезапуска старого хаба, а старый хаб эти ключи читает. Новый код
`base_jql` из `setting` не читает (вычисляет из проектов), а `board_id` остаётся в `AGENT_KEYS` как запасной для
агента: по нему агент v12 выполнит `ensure_sprint`, поставленные до миграции без `board_id`. Ключи убираются
отдельной задачей после выкатки (follow-up). В `DEFAULTS` новой версии ключей `base_jql` и `board_id` нет — на новой
базе их не появляется. Если `base_jql` не разобрана — проектов нет, в лог — предупреждение с прежним значением.
`jira-hub init --config` заводит проект `AWPCA` «АРМ КА» с `board_id` из конфига вместо записи `base_jql`/`board_id`.
5. **Разрез по проекту — одна функция зеркала для доски и списка.** Для проекта P: *свои* — задачи зеркала с меткой P;
*соседи* — задачи без метки P, связанные со своими связью (в любую сторону), и эпики своих (`epic_key`), если они есть в
зеркале. `GET /board?project=P` отдаёт своих и соседей, `neighbor = false` у своих и `true` у соседей; `links` — связи,
у которых хотя бы один конец свой. `GET /issues?project=P` — та же выборка, фильтр `neighbor` относится к признаку
относительно P; остальные фильтры — поверх. Без `project` — прежнее поведение (всё зеркало, `neighbor` из колонки).
Неизвестный проект — 404. `last_sync` — общий (прогон один на все проекты). Доска признак `neighbor` не читает
(`fromHub.ts`): соседи по связям из других проектов рисуются обычными карточками, как сейчас рисуются соседи зеркала;
«+ соседи по связям» доска считает сама по связям. Признак `neighbor` относительно проекта виден в колонке «Сосед»
списка задач. Владелец связей между проектами не ожидает, поэтому отдельной отрисовки соседей-из-другого-проекта нет.
6. **Пакет проекта — проверки в хабе, не в MCP.** `POST /batches {project: P, …}`: неизвестный P — 422. Для каждой
команды:
- `ensure_issue` — если в `labels` нет P, хаб дописывает P в конец (параметры хранятся уже с ней); метка другого
проекта в `labels` — 422;
- `ensure_fields` — `labels_remove` с P или `labels_add` с меткой другого проекта — 422;
- «метка другого проекта» и «P в `labels_remove`» сравниваются **без учёта регистра** (Jira в JQL сравнивает метки
так же): `awpca` в пакете STOCKS_DRRK — тоже чужая метка;
- команды над существующей задачей (`ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`,
`ensure_attachment`) — `key` должен быть временным ключом этого пакета или задачей зеркала с меткой P, иначе 422
«<key> не в проекте P»;
- `ensure_link` — хотя бы один из `source`, `target` свой (временный ключ пакета или задача с меткой P);
- `refresh` — без ограничений.
Пакет хранит `batch.project = P`; `GET /batches?project=P` — только пакеты проекта. `POST /commands/{id}/retry`
принимает необязательный `project`: передан и у исходного пакета другой проект (в том числе `NULL`) — 422
«команда #N не из проекта P»; передан и совпадает — новый пакет с P и проверками; не передан — новый пакет получает
проект исходного. MCP в привязанной сессии всегда передаёт свой P, поэтому сессия не может повторить чужую команду
или команду пакета без проекта. Без `project` — прежнее поведение, проверок нет,
`batch.project = NULL` (автообновление, веб-приложение, старые клиенты). Проверки в хабе, а не в MCP: MCP по
границам ходит только через `client`, а правило о метке проекта должно действовать для любого клиента, который
назвал проект.
7. **Доски — в параметрах команд, настройки `board_id` нет.** Агенту не нужен список проектов, ему нужны доски:
- `ensure_sprint` — при постановке хаб определяет проект задачи (метки задачи в зеркале ∩ проекты; для временного
ключа — метки её `ensure_issue` в пакете) и дописывает в параметры `board_id` его доски. 422 при постановке: задача
ни в одном проекте; в нескольких проектах с разными досками; у проекта нет доски; доска `kanban` («у проекта P
канбан-доска, спринтов нет»); тип доски ещё не известен («дождитесь обновления из Jira»). `sprint: "backlog"` на
scrum-доске — как раньше. `board_id` от клиента в `ensure_sprint` — 422 (его выставляет хаб). `board_id` хранится
в `params` (видно в истории команд, переживает довыдачу); повтор команды (`retry`) снимает `board_id` с параметров
до проверки и вычисляет доску заново.
- `refresh` — при выдаче (`deliver_params`, в базе не хранится) хаб дописывает `boards: [{id, type}]` — все доски
проектов без повторов, `type` — известный тип или `null`. Агент для досок с `type = null` запрашивает
`GET /rest/agile/1.0/board/{id}` и возвращает в результате команды `boards: {"<id>": "scrum" | "kanban" | null}`
(`null` — доска не найдена или нет доступа; причина — в лог агента); хаб записывает известные типы в `project`.
Имена спринтов агент собирает со всех scrum-досок (известных и только что определённых); канбан-доски пропускает.
Одна задача в спринтах двух досок (или двух спринтов одной) — побеждает последнее найденное имя, как в v11
(`sprint_names` перезаписывает).
Агент v12 без `boards`/`board_id` в параметрах (старый хаб или команда, поставленная до миграции) берёт
`settings.board_id`, как v11 (ключ остаётся до follow-up, решение 4).
8. **MCP: привязка к проекту флагом запуска.** `jira-hub mcp --project <метка>`. Привязка проверяется при каждом вызове
(хаб может быть не запущен при старте MCP): метки нет в `GET /projects` — инструмент отвечает «проект <метка> не заведён
в хабе: project_create или исправьте --project в .mcp.json».
В привязанной сессии:
- `issues` — `GET /issues?project=P`; `issue` — любая задача по ключу (явный ключ — осознанный запрос, а карточка
соседа нужна при работе со связями);
- `notes_set` — только своя задача проекта (метка P; соседи — нет), иначе отказ;
- `batch_create`, `command_retry` — с `project = P` (проверки решения 6);
- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения;
- `refresh` — `full` — общий полный прогон, `keys` — как раньше.
Без привязки инструменты `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh` отвечают «сессия
не привязана к проекту: …» с подсказкой про `--project` и `projects`; остальные (`projects`, `project_create`,
`agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`)
работают. Решение владельца: «только свои»; безопасное поведение без привязки — моё (см. ниже).
9. **`project_create` в MCP** — `POST /projects {label, name, board_id?, author: "claude"}`; ответ — проект, id пакета
обновления и готовый фрагмент `.mcp.json`:
`{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp",
"--project", "<метка>"]}}}` с инструкцией: положить в корень репозитория проекта (слить с имеющимся `.mcp.json`) и
сделать `/mcp` → reconnect (одобрить project-scope сервер). Запись `jira-hub` в project scope перекрывает одноимённую
из user scope. Корень хаба MCP вычисляет от расположения своего пакета (`src/jira_hub/` → корень репозитория), не
зашивая путь; путь к `.venv` в подсказку не попадает (`uv run` восстановить из `sys.argv` нельзя). Если MCP запущен
из worktree, в подсказке окажется путь worktree — это видно в ответе, рабочий MCP запускается из основного checkout.
`projects` — таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач;
в привязанной сессии отмечает текущий проект.
10. **`POST /projects`** — `{label, name, board_id?, author?}` → 201 `{project, batch_id}`; проект и пакет «Новый проект
<метка>» из одного полного `refresh` ставятся в одной транзакции; автор пакета — `author` (по умолчанию `api`).
Ошибки: 422 — неверные поля (решение 2), 409 — метка уже есть. `GET /projects` → список
`{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; `issues_count` —
задачи зеркала с меткой проекта.
11. **Веб-приложение — проект в адресе и в памяти браузера.** Выбор проекта — выпадающий список рядом с «Хаб Jira»
(вариант A): «<метка> — <название>», текущий отмечен; в конце — «+ Новый проект…». Текущий проект — параметр
`project` в адресе `/app/board` и `/app/issues`; без него — последний выбранный (`localStorage`), иначе первый из
`GET /projects`; выбранный записывается в адрес (`replace`). Неизвестный проект в адресе — сообщение «проект <метка>
не найден» со списком проектов. Нет ни одного проекта — доска и список показывают приглашение создать проект.
Переходы «Доска» / «Задачи» сохраняют проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит.
Форма «Новый проект» — окно поверх страницы: метка, название, номер доски (необязательно); ошибка хаба (`detail`)
— рядом с кнопкой «Создать»; после создания — переключение на новый проект. Пакет создания ставится с автором
`viewer`, поэтому доска подхватывает его как свой `refresh` (правило «Источник данных доски») и показывает ход.
12. **Доска в разрезе проекта.** `GET /board?project=P`; «Обновить из Jira» — как раньше `POST /refresh {full: true}` —
обновляет все проекты (решение владельца). Состояние доски в `localStorage` — отдельно на проект. Сейчас в
`web/src/board/storage.ts` ключи от имени источника не зависят: `fileKey(n)` = `graph-<n>:board` (pos, posdate,
lanes, fold, sprint, who, colw) и `pref(n)` = `graph-<n>` (флаги neighbors, onlylinked, crit, bydate, hidekind,
hideedge, snap, tbl, prob). На проект делятся **оба набора**: у проекта `AWPCA` — ровно нынешние ключи, у
остальных — те же ключи с суффиксом `@<метка>` (`graph-pos:board@STOCKS_DRRK`, `graph-crit@STOCKS_DRRK`); общие для
всего приложения настройки вне доски (если есть) не делятся. Загрузка CSV/JSON и `applyJsonSettings` пишут в ключи
текущего проекта. Так раскладка AWPCA переживает выкатку, а проекты не смешиваются. Фильтр спринтов: `hasSprint` в модели из хаба — есть ли хотя бы одна **своя** задача (`neighbor = false`) со спринтом (даже если канбан-проект связан с задачами scrum-проекта); у
канбан-проекта фильтра спринтов нет («колонки «Спринт» нет — показаны все задачи», как у CSV без спринтов).
13. **Список задач в разрезе проекта.** `GET /issues?project=P&…`; варианты фильтров — из снимка `GET /board?project=P`;
параметр `project` в адресе — рядом с фильтрами; фильтр «Метка» остаётся.
14. **Порядок выкатки.** (1) Агент v12 опубликован и `agent_status` показывает v12 — он понимает и `boards`/`board_id` в
параметрах, и прежний `settings.board_id`. (2) Слияние хаба, `make web`, перезапуск хаба (владелец) — миграция v4.
(3) `min_agent_version = 12`. (4) В `awp-ca` положить `.mcp.json` с `--project AWPCA` и переподключить MCP — до этого
сессии `awp-ca` с задачами работать не могут (решение 8). Хаб новее агента v11 до шага (1) не выкатывать: v11 не
знает `boards` и `board_id` в параметрах и читает спринты только с доски из настроек — спринты задач других
проектов пропадут из зеркала, а `ensure_sprint` для них уйдёт не на ту доску. Миграция v4 нестрашна для старого
хаба, если базу откроет новый MCP/CLI раньше перезапуска (решение 4), но между `git pull` и перезапуском хаба лучше
не открывать новые сессии MCP.
15. **Агент v12 — отдельные issue, ветка, design-only change и PR в `jira-agent`.** Контракт (параметры `boards`,
`board_id`, результат `boards`) — в этом change (спека `agent` живёт в хабе); у агента — решения реализации.
Агент сливается и публикуется первым (решение 14).
## Risks / Trade-offs
- Задача с метками двух проектов видна в обоих; `ensure_sprint` для неё при разных досках — 422. Осознанно: владелец
связей между проектами не ожидает.
- Задача проекта без метки проекта (метку сняли в Jira руками) выпадает из полной JQL и остаётся в зеркале со старыми
данными, как сейчас.
- Без `.mcp.json` в `awp-ca` сессии там после выкатки не видят задач — это намеренно (анти-цель «не та метка»), но шаг
выкатки легко забыть; он в чеклисте выкатки PR.
- `refresh {keys}` хвостом пакета тоже получает `boards` и тратит по одному вызову `GET /board/{id}` на доску с
неизвестным типом — только до первого определения.
- Ошибка в номере доски при создании или доска, понадобившаяся канбан-проекту позже, интерфейсом не исправляются (правки
проекта нет). Решение владельца 19.10.2026: на первое время он правит базу сам —
`UPDATE project SET board_id = <номер>, board_type = NULL WHERE label = '<метка>'`; тип доски определит следующий
`refresh`. Агенты (Claude) базу хаба напрямую не правят. Доска, на которую агент ответил «не найдена» (`null`), остаётся
с `board_type = NULL`: каждый `refresh` снова запрашивает её тип, `ensure_sprint` отвечает «тип доски ещё не известен».
- Создание проекта меняет базовую JQL, поэтому `last_sync` доски становится `null` («полного прогона не было»), пока
не завершится пакет «Новый проект» — он ставится сразу, доска показывает его ход. Переписывать `sync_run.query` здесь
было бы неправдой: прежний прогон новую метку не покрывал.
- Сравнение меток без учёта регистра (решение 2) при создании; в разрезе проекта (решение 5) метка сравнивается точно,
как в зеркале (`json_each`). Jira хранит метку так, как её записали, — расхождение возможно только при ручной правке
метки в Jira другим регистром.
## Decisions made without discussion
1. 2026-10-19 — **проект — таблица `project`, а не ключ настроек.** Обоснование: сущность со своим циклом; правка не
должна поднимать `settings_version` и перечитывание настроек агентом. Цена ошибки: таблица и миграция; перенос в
настройки — одна миграция назад.
2. 2026-10-19 — **сессия MCP без `--project` не читает и не пишет задачи.** Обоснование: анти-цель — задача с меткой не
того проекта; владелец согласился с этим в обсуждении («все ок»), сам режим «только служебные инструменты» — моё
уточнение. Цена ошибки: `awp-ca` до своего `.mcp.json` не работает с задачами; смягчить — умолчание по проекту.
3. 2026-10-19 — **проверки метки и принадлежности задачи проекту — в хабе (`POST /batches {project}`), а не в MCP.**
Обоснование: граница «MCP только через client», правило нужно и будущим клиентам. Цена ошибки: поле пакета и
проверки в `queue`; перенос в MCP — без изменения базы.
4. 2026-10-19 — **хаб дописывает метку проекта в `ensure_issue` сам (в пакете с `project`).** Прежнее правило «хаб метки
сам не добавляет» остаётся для пакетов без проекта. Обоснование: Claude не должен помнить метку — это и есть
анти-цель. Цена ошибки: одна ветка в проверках постановки.
5. 2026-10-19 — **`issue` в MCP открывает любую задачу по ключу, `issues` — только проект.** Обоснование: явный ключ —
осознанный запрос, карточка соседа нужна при работе со связями. Цена ошибки: одна проверка в MCP.
6. 2026-10-19 — **доски передаются агенту в параметрах команд (`ensure_sprint.board_id` при постановке,
`refresh.boards` при выдаче), тип доски определяет агент в `refresh`.** Обоснование: хаб не ходит в Jira; отдельная
команда «узнать доску» потребовала бы нового типа команды. Цена ошибки: два параметра контракта.
7. 2026-10-19 — **тип доски неизвестен → `ensure_sprint` 422, а не попытка на удачу.** Обоснование: создание проекта
само ставит `refresh`, окно короткое. Цена ошибки: одна ошибка постановки сразу после создания проекта.
8. 2026-10-19 — **разрез по проекту: свои + соседи по связям + эпики своих; `neighbor` относительно проекта.**
Обоснование: логика доски и «+ соседи» уже построена на `neighbor`; эпики нужны доске для имён. Цена ошибки:
функция выборки и тесты к ней.
9. 2026-10-19 — **миграция называет проект AWPCA «АРМ КА», а `board_id` из настроек отдаёт ему.** Обоснование: так
называется проект в workspace; доска 4216 — его. Цена ошибки: название правится будущим «переименованием» или
вручную.
10. 2026-10-19 — **раскладка доски AWPCA остаётся на прежних ключах `localStorage`, остальные проекты — на ключах с
меткой.** Обоснование: анти-цель «AWPCA потерял раскладку». Цена ошибки: особый случай в одном месте `storage.ts`.
11. 2026-10-19 — **`project_create` отвечает готовым `.mcp.json` с командой запуска текущего процесса MCP.**
Обоснование: владелец хочет, чтобы Claude сам прописал проект в репозитории; путь хаба не зашивается. Цена ошибки:
формат подсказки.
12. 2026-10-19 — **метка проекта без пробелов, запятых, кавычек, скобок; уникальность без учёта регистра.**
Обоснование: безопасная подстановка в JQL. Цена ошибки: отказ на экзотической метке.
13. 2026-10-19 — **`GET /batches?project=` и `batches` в привязанной сессии — только пакеты проекта;
`batch_status/wait/cancel` по id — без ограничения.** Обоснование: список не засоряется чужими пакетами, а явный id —
осознанный запрос. Цена ошибки: фильтр списка.
14. 2026-10-19 — **миграция v4 не удаляет `base_jql` и `board_id` из `setting`; `board_id` остаётся запасной доской
агента; удаление — follow-up. `GET/PUT /settings` по-прежнему отдаёт `board_id` как есть (запасная доска агента), а
`base_jql` — вычисляемый из меток проектов.** Обоснование: базу мигрирует любой процесс новой версии (MCP, CLI), возможно — под
работающим старым хабом (находка builder check). Цена ошибки: два мёртвых ключа до follow-up.
15. 2026-10-19 — **миграция переписывает `params.jql` у `refresh` в `queued`/`claimed` со старой базовой JQL.**
Обоснование: иначе прогон из очереди не признается полным. Цена ошибки: одна строка миграции.
16. 2026-10-19 — **`retry` с `project` отказывает, если проект исходного пакета другой; хаб снимает свой `board_id`
при повторе.** Обоснование: сессия проекта не должна повторять чужие команды; иначе повтор `ensure_sprint` падал бы
на своей же проверке «board_id от клиента». Цена ошибки: поле тела `retry` и одна ветка.
17. 2026-10-19 — **чужая метка проекта и `labels_remove` P — без учёта регистра; принадлежность задачи — точно.**
Обоснование: Jira в JQL сравнивает метки без учёта регистра — `awpca` попала бы в общую JQL, но ни в чей разрез.
Цена ошибки: сравнение в проверках.
18. 2026-10-19 — **`notes_set` в привязанной сессии — только свои задачи, без соседей.** Обоснование: «только свои»
владельца. Цена ошибки: одна проверка.
19. 2026-10-19 — **фрагмент `.mcp.json` — `uv run --project <корень хаба от расположения пакета>`.** Обоснование:
вызов `uv run` из `sys.argv` не восстановить, путь к `.venv` в чужом репозитории хрупок. Цена ошибки: формат
подсказки.
20. 2026-10-19 — **на проект делятся и раскладка (`fileKey`), и флаги доски (`pref`); AWPCA — на нынешних ключах,
остальные — с суффиксом `@<метка>`.** Обоснование: спека требует хранить фильтры отдельно; анти-цель — потеря
раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`.
21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не
переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске.
22. 2026-10-19 — **пустая строка и отсутствие `author` в `POST /projects` — как `api`; любое другое нестроковое значение (в том числе `0`, `false`, `[]`, `{}`) — 422 `bad_project`.** Обоснование: как у
остальных маршрутов (пусто — `api`); спека требует 422 для не-строки, и `0`/`false` не должны молча становиться `api`. Цена ошибки: пустая
строка пишется автором `api`, а не отвергается.
23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.**
Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и
`bad_project`. Цена ошибки: код ошибки в ответе.
24. 2026-10-19 — **`GET /batches?project=` с неизвестной меткой — пустой список, а не 404; пустой `project=` — как без
фильтра.** Обоснование: спека требует 404 только для `/issues` и `/board`; у пакетов проект — произвольная
сохранённая метка. Цена ошибки: одна ветка в `h_batches_get`.
25. 2026-10-19 — **в `ensure_issue` пакета проекта повторы других (не P) меток не убираются.** Обоснование: спека
убирает только повтор P; остальное — как прислал клиент. Цена ошибки: одна строка в `_check_project`.
26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command`
сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда
#N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422.
27. 2026-10-19 — **типы досок из результата `refresh` пишутся в любом статусе отчёта (`done` и `failed`), если в
результате-словаре есть ключ `boards`; без ключа или с мусорными значениями типы не трогаются.** Обоснование: тип
доски — факт о доске, а не часть выборки: проваленная выборка задач не делает присланный тип неверным; спека
commands требует записать присланные типы по результату refresh. Поздний отчёт по уже терминальной команде типы не
пишет (ветка терминальной команды возвращается раньше). Цена ошибки: одно условие в `report_result`.
28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`.
29. 2026-10-19 — **проверка метки сессии (`GET /projects`) выполняется внутри `guard`, поэтому при выключенном хабе
привязанная сессия отвечает «хаб не запущен: jira-hub serve», а не «проект не заведён»; в async-инструментах
проверка идёт через `asyncio.to_thread`.** Обоснование: Review Focus 4 плана; `scoped` стоит под `guard`. Цена
ошибки: порядок декораторов; лишний `GET /projects` на каждый вызов (в `refresh(wait=True)` — второй, из
`batch_wait`).
30. 2026-10-19 — **`projects` и `project_create` без привязки не проверяют метку сессии; `notes_set` для не временного
ключа проверяет принадлежность по `labels` из `GET /issues/{key}` (точное совпадение, как в хабе).** Обоснование:
спека исключает эти два инструмента из проверки; «только своя задача» требует метки P. Цена ошибки: одна ветка в
`notes_set`.
31. 2026-10-19 — **`ProjectGate` в состоянии «проект из адреса не найден» не рисует второй выпадающий список: выбор — в
списке «Проект» верхней панели, страница даёт текст «проект <метка> не найден» и подсказку.** Обоснование: Task 8
брифа велел поставить `ProjectPicker` и в страницу, но два `combobox` с одним именем «Проект» двусмысленны для
доступности и тестов (`getByRole` падает); панель с выбором присутствует всегда, спека («вместо данных» — данные не
показываются) выполнена. Цена ошибки: вернуть `<ProjectPicker />` в ветку `unknown` и `getAllByRole` в тесте.
32. 2026-10-19 — **`useCreateProject` в `onSuccess` возвращает промис инвалидации `qk.projects`, поэтому
`onCreated` вызывается уже после перечитывания списка.** Обоснование: иначе `select(новая метка)` выполнялся бы
раньше, чем метка появится в списке, и страница мигнула бы «проект не найден». Цена ошибки: вспышка сообщения на
долю секунды; ожидание — один лишний `GET /projects` перед закрытием окна.
33. 2026-10-19 — **служебное значение пункта «+ Новый проект…» в `<select>` — строка `" 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` не нужен.
Цена ошибки: нет (поведение и порядок те же).

View File

@ -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 = <project_key> 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 после выкатки потерял задачи, заметки, раскладку доски или историю синхронизаций.
- Сессия в репозитории одного проекта по умолчанию видит и меняет задачи другого.

View File

@ -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: {"<id>": "scrum" | "kanban" | null}` (`null` —
доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена
спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа
`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Если доска, которую хаб передал как `scrum`, не отдала спринты, агент SHALL перепроверить её тип: стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}` (хаб записывает тип и при прежнем `scrum`); иначе `refresh` — `failed` с текстом «доска <id>: …» (агент 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<N>, проект
<KEY>»; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1
#### Scenario: Самопроверка новой версии
- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны
- **THEN** печатается «jira-agent v<N>» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется;
если хаб или Jira недоступны — код 3

View File

@ -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 «<key> не в проекте 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:<n>`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = <proj> AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.<issue_type>` (для эпика — с `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: {"<id>": "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: <sha>, 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`

View File

@ -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 = <project_key> 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 = <M>` или `… labels in (<M1>, …)` и завести проекты с этими метками (название проекта
`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` с обеими метками

View File

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

View File

@ -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:<n>` (его задача создаётся пакетом
проекта), иначе ответ «<key> не в проекте 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<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `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: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### 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»

View File

@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Инициализация настроек
`jira-hub init --config <admin/jira-sync/config.example.json>` 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`

View File

@ -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`, в таблице задачи в статусе «Готово»

View File

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

View File

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

View File

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

View File

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

View File

@ -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,14 +165,25 @@ 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:
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")]

172
src/jira_hub/projects.py Normal file
View File

@ -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:
"""Записать типы досок {"<id>": "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<one>\S+)|in\s*\((?P<many>[^()]*)\))\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))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

148
tests/test_projects.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) => {

View File

@ -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$/);
});

49
web/e2e/projects.spec.ts Normal file
View File

@ -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();
});
});

View File

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

View File

@ -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(<App router={at("/app")} />);
expect(await screen.findByTestId("page-board")).toBeInTheDocument();
// страница сначала показывает заглушку «Загрузка настроек», затем её заменяет другой элемент с тем же testid
await waitFor(() => expect(screen.getByTestId("page-board")).toBeInTheDocument());
});
it("переход «Задачи» и обратно", async () => {
@ -23,9 +25,11 @@ describe("оболочка", () => {
render(<App router={router} />);
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();
});

View File

@ -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<IssueDetail>(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);
});
});

View File

@ -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<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed", string>>;
export type IssueFilters = Partial<Record<(typeof ISSUE_FILTER_NAMES)[number] | "hide_closed" | "project", string>>;
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<HubSettings>("/settings"), staleTime: Infinity });
export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get<BoardSnapshot>("/board") });
export const useBoard = (project: string) =>
useQuery({ queryKey: qk.board(project), queryFn: () => api.get<BoardSnapshot>("/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<CreatedProject>("/projects", { ...f, author: "viewer" }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), // ждём список: onCreated уже видит новый проект
});
}
export const useIssue = (key: string) =>
useQuery({ queryKey: qk.issue(key), queryFn: () => api.get<IssueDetail>(`/issues/${encodeURIComponent(key)}`) });
@ -41,7 +57,7 @@ export function useSaveNote(key: string) {
onSuccess: (note) => {
qc.setQueryData<IssueDetail>(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 });
},
});
}

View File

@ -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<string, number>; remaining_seconds: number; commands?: BatchCommand[];
counts: Record<string, number>; 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 }

View File

@ -9,7 +9,7 @@ import { clearToken, getAuth, setToken } from "./token.ts";
const app = <AuthGate><p>страница приложения</p></AuthGate>;
function Page() { useBoard(); return <p>страница приложения</p>; }
function Page() { useBoard("AWPCA"); return <p>страница приложения</p>; }
describe("вход", () => {
beforeEach(() => clearToken());
@ -76,7 +76,7 @@ describe("вход", () => {
const { client } = renderWithProviders(<AuthGate><Page /></AuthGate>);
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" });
});

View File

@ -26,7 +26,12 @@ function hub(over: Record<string, () => Response> = {}) {
return json(404, { error: "not_found" });
});
}
const page = () => renderWithProviders(<BoardPage />, { route: "/board", path: "/board" });
const page = (project = "AWPCA") => renderWithProviders(<BoardPage project={project} />, { 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(<StrictMode><BoardPage /></StrictMode>, { route: "/board", path: "/board" });
renderWithProviders(<StrictMode><BoardPage project="AWPCA" /></StrictMode>, { 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();
});
});

View File

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

View File

@ -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 };
}

View File

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

View File

@ -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([]);
});
});

View File

@ -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<T>(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<string, unknown> => !!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 {

View File

@ -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.BoardPrefs>(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); });
export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi {
const [prefs, setPrefs] = useState<st.BoardPrefs>(() => {
st.setStoreProject(project); // ключи проекта — до любого чтения; страница пересоздаётся при смене проекта (key={project})
if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); }
return st.readPrefs();
});
const [store, setStore] = useState<LayoutStore>(() => readStore(0));
const [sel, setSel] = useState(() => pick(model, prefs.filters));
if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho

View File

@ -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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> }) });
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deferred = <T,>() => { let resolve!: (v: T) => void; const promise = new Promise<T>((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());

View File

@ -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<Source>({ kind: "hub" });
const [event, setEvent] = useState<BoardStatus | null>(null);
const [active, setActive] = useState<Active | null>(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 модели зависит выбор спринтов на странице)

View File

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

View File

@ -30,9 +30,10 @@ describe("список задач", () => {
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument();
});
it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => {
hub(() => [row(1)]);
const { router } = renderWithProviders(<IssuesPage />, { route: "/issues?q=свод", path: "/issues" });
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
const { client } = renderWithProviders(<IssuesPage project="AWPCA" />, { 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<Response>(() => {}); // снимок не приходит до конца теста
return json(200, { issues: [row(1)] });
});
renderWithProviders(<IssuesPage />, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues", path: "/issues" });
const { router } = renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?closed=1", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?status=Готово", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?status=В работе", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?status=Снято", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { 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(<IssuesPage />, { route: "/issues?fact_status=В работе", path: "/issues" });
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues?fact_status=В работе", path: "/issues" });
await screen.findByRole("link", { name: "AURORA-9001" });
expect(screen.queryByLabelText("Статус по факту")).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull();

View File

@ -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<IssueFilters>(() =>
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<IssueFilters>(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }),
[filters, showClosed, closed]);
const query = useMemo<IssueFilters>(() => (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() ?? [];

View File

@ -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): <dialog> держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать»
export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void }) {
const ref = useRef<HTMLDialogElement>(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 (
<dialog ref={ref} className="project-dialog" aria-label="Новый проект"
onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>
<form onSubmit={submit}>
<h2>Новый проект</h2>
<label>Метка<input value={label} onChange={(e) => setLabel(e.target.value)} required /></label>
<label>Название<input value={name} onChange={(e) => setName(e.target.value)} required /></label>
<label>Номер доски Jira
<input type="number" min={1} value={board} onChange={(e) => setBoard(e.target.value)} placeholder="scrum или kanban определится сам" />
</label>
<div className="actions">
<button className="btn" type="submit" disabled={create.isPending}>Создать</button>
<button className="btn" type="button" onClick={onClose} disabled={create.isPending}>Отмена</button>
{text && <span className="warn" role="alert">{text}</span>}
</div>
</form>
</dialog>
);
}

View File

@ -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<ProjectCtx | null>(null);
export function useProjectContext(): ProjectCtx {
const c = useContext(ProjectContext);
if (!c) throw new Error("ProjectContext: вне оболочки приложения");
return c;
}

View File

@ -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 <p className="hint">Загрузка проектов…</p>;
case "error": return <p className="warn" role="alert">Проекты не загрузились: {state.error.message}</p>;
case "unknown":
return <div className="project-gate" data-testid="project-unknown"><p className="warn">проект {state.label} не найден</p><p className="hint">Выберите проект в списке «Проект» вверху.</p></div>;
case "none":
return <div className="project-gate"><p className="hint">Проектов нет.</p>
<button className="btn" type="button" onClick={openNew}>+ Новый проект…</button></div>;
case "ok": return <>{children(state.project)}</>;
}
}

View File

@ -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 (
<select className="project-picker" aria-label="Проект" value={current}
onChange={(e) => { if (e.target.value === NEW) openNew(); else select(e.target.value); }}>
{current === "" && <option value="" disabled>выберите проект</option>}
{projects.map((p) => <option key={p.label} value={p.label}>{p.label} — {p.name}</option>)}
<option value={NEW}>+ Новый проект…</option>
</select>
);
}

View File

@ -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();
});
});

View File

@ -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();
}

View File

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

View File

@ -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<string, (body: unknown) => Response | Promise<Response>> = {}) {
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(<App router={router} />);
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<void>((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();
});
});

View File

@ -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<ProjectState>(() => 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<ProjectCtx>(() => ({ 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 <Link to={to} className={active ? "active" : undefined} aria-current={active ? "page" : undefined}>{label}</Link>;
return <Link to={okLabel ? { pathname: to, search: withProject("", okLabel) } : to} className={active ? "active" : undefined}
aria-current={active ? "page" : undefined}>{label}</Link>;
};
return (
<>
<ProjectContext value={ctx}>
<header className="appbar">
<span className="brand">Хаб Jira</span>
<ProjectPicker />
<nav>{tab("/board", "Доска")}{tab("/issues", "Задачи")}</nav>
<span className="spacer" />
<button type="button" className="link" onClick={() => { clearToken(); qc.clear(); }}>Сменить токен</button>
@ -64,6 +95,7 @@ export function Layout() {
<ErrorBoundary key={at}>{page}</ErrorBoundary>
</main>
{issueKey && <CloseAbortContext value={abortClose}><IssueModal issueKey={issueKey} onClose={close} /></CloseAbortContext>}
</>
{newOpen && <NewProjectDialog onClose={() => setNewOpen(false)} onCreated={(p) => { setNewOpen(false); select(p.label); }} />}
</ProjectContext>
);
}

View File

@ -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: <Navigate to="/board" replace /> },
{ path: "/board", element: <BoardPage /> },
{ path: "/issues", element: <IssuesPage /> },
{ path: "/board", element: <ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "/issues", element: <ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate> },
{ path: "*", element: <NotFound /> },
];