Несколько проектов-меток в мосте (#61) #62
@ -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` и своих копий не держит.
|
||||
|
||||
@ -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` — опубликовать версию агента для самообновления (только вверх).
|
||||
|
||||
## Тесты
|
||||
|
||||
68
README.md
68
README.md
@ -1,6 +1,6 @@
|
||||
# jira-hub
|
||||
|
||||
Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд,
|
||||
Хаб моста Jira для АРМ КА: зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.) в SQLite, служебные заметки, очередь команд,
|
||||
HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только
|
||||
агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен.
|
||||
Спека: `openspec/changes/bridge-core/` (proposal, design, specs).
|
||||
@ -59,9 +59,25 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj
|
||||
|
||||
После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect.
|
||||
|
||||
Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта
|
||||
(метка сравнивается точно, с учётом регистра):
|
||||
|
||||
```json
|
||||
{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "--project", "AWPCA"]}}}
|
||||
```
|
||||
|
||||
Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`,
|
||||
`agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`);
|
||||
`issues`, `issue`, `notes_set`, `batch_create`, `command_retry` и `refresh`
|
||||
отвечают «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)». Метка, которой нет в
|
||||
хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии
|
||||
`issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:<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` берёт его из
|
||||
|
||||
1047
docs/superpowers/plans/2026-10-19-projects-hub.md
Normal file
1047
docs/superpowers/plans/2026-10-19-projects-hub.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/projects/.openspec.yaml
Normal file
2
openspec/changes/projects/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-19
|
||||
359
openspec/changes/projects/design.md
Normal file
359
openspec/changes/projects/design.md
Normal 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` не нужен.
|
||||
Цена ошибки: нет (поведение и порядок те же).
|
||||
57
openspec/changes/projects/proposal.md
Normal file
57
openspec/changes/projects/proposal.md
Normal 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 после выкатки потерял задачи, заметки, раскладку доски или историю синхронизаций.
|
||||
- Сессия в репозитории одного проекта по умолчанию видит и меняет задачи другого.
|
||||
55
openspec/changes/projects/specs/agent/spec.md
Normal file
55
openspec/changes/projects/specs/agent/spec.md
Normal 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
|
||||
117
openspec/changes/projects/specs/commands/spec.md
Normal file
117
openspec/changes/projects/specs/commands/spec.md
Normal 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`
|
||||
156
openspec/changes/projects/specs/data-model/spec.md
Normal file
156
openspec/changes/projects/specs/data-model/spec.md
Normal 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` с обеими метками
|
||||
185
openspec/changes/projects/specs/hub-api/spec.md
Normal file
185
openspec/changes/projects/specs/hub-api/spec.md
Normal 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
|
||||
134
openspec/changes/projects/specs/mcp/spec.md
Normal file
134
openspec/changes/projects/specs/mcp/spec.md
Normal 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»
|
||||
16
openspec/changes/projects/specs/migration/spec.md
Normal file
16
openspec/changes/projects/specs/migration/spec.md
Normal 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`
|
||||
98
openspec/changes/projects/specs/web-app/spec.md
Normal file
98
openspec/changes/projects/specs/web-app/spec.md
Normal 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`, в таблице задачи в статусе «Готово»
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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)")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
172
src/jira_hub/projects.py
Normal 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))
|
||||
@ -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}}])
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
148
tests/test_projects.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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
49
web/e2e/projects.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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"]:
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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" });
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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 модели зависит выбор спринтов на странице)
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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() ?? [];
|
||||
|
||||
|
||||
41
web/src/projects/NewProjectDialog.tsx
Normal file
41
web/src/projects/NewProjectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
web/src/projects/ProjectContext.ts
Normal file
20
web/src/projects/ProjectContext.ts
Normal 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;
|
||||
}
|
||||
18
web/src/projects/ProjectGate.tsx
Normal file
18
web/src/projects/ProjectGate.tsx
Normal 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)}</>;
|
||||
}
|
||||
}
|
||||
16
web/src/projects/ProjectPicker.tsx
Normal file
16
web/src/projects/ProjectPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
web/src/projects/current.test.ts
Normal file
35
web/src/projects/current.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
35
web/src/projects/current.ts
Normal file
35
web/src/projects/current.ts
Normal 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();
|
||||
}
|
||||
10
web/src/projects/projects.css
Normal file
10
web/src/projects/projects.css
Normal 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}
|
||||
188
web/src/projects/projects.test.tsx
Normal file
188
web/src/projects/projects.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 /> },
|
||||
];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user