From 5ef6c937b5c2591f255a7cca0196b5b792301149 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 18:59:33 +0400 Subject: [PATCH 01/16] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0=20OpenSpec?= =?UTF-8?q?:=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2-=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=20=D0=B2=20=D0=BC=D0=BE=D1=81=D1=82=D0=B5=20?= =?UTF-8?q?(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/.openspec.yaml | 2 + openspec/changes/projects/design.md | 250 ++++++++++++++++++ openspec/changes/projects/proposal.md | 57 ++++ openspec/changes/projects/specs/agent/spec.md | 55 ++++ .../changes/projects/specs/commands/spec.md | 116 ++++++++ .../changes/projects/specs/data-model/spec.md | 147 ++++++++++ .../changes/projects/specs/hub-api/spec.md | 185 +++++++++++++ openspec/changes/projects/specs/mcp/spec.md | 133 ++++++++++ .../changes/projects/specs/migration/spec.md | 16 ++ .../changes/projects/specs/web-app/spec.md | 98 +++++++ 10 files changed, 1059 insertions(+) create mode 100644 openspec/changes/projects/.openspec.yaml create mode 100644 openspec/changes/projects/design.md create mode 100644 openspec/changes/projects/proposal.md create mode 100644 openspec/changes/projects/specs/agent/spec.md create mode 100644 openspec/changes/projects/specs/commands/spec.md create mode 100644 openspec/changes/projects/specs/data-model/spec.md create mode 100644 openspec/changes/projects/specs/hub-api/spec.md create mode 100644 openspec/changes/projects/specs/mcp/spec.md create mode 100644 openspec/changes/projects/specs/migration/spec.md create mode 100644 openspec/changes/projects/specs/web-app/spec.md diff --git a/openspec/changes/projects/.openspec.yaml b/openspec/changes/projects/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/projects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md new file mode 100644 index 0000000..6ffacd4 --- /dev/null +++ b/openspec/changes/projects/design.md @@ -0,0 +1,250 @@ +## 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. **Метка проекта** — непустая строка без пробельных символов, запятых, кавычек и скобок (метка Jira не может + содержать пробел; остальное исключено, чтобы метку можно было без экранирования вставить в JQL `labels in (…)`), до + 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает + метки без учёта регистра в JQL). Название — непустое после обрезки пробелов, до 100 символов. `board_id` — целое > 0 + или отсутствует. +3. **Базовая JQL собирается, а не хранится.** `base_jql = "project = AND labels in (<метки по + 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 = ` или `… labels in (, …)` — завести проекты с этими + метками; название проекта `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`. +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 + « не в проекте 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: {"": "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-:board` (pos, posdate, + lanes, fold, sprint, who, colw) и `pref(n)` = `graph-` (флаги neighbors, onlylinked, crit, bydate, hidekind, + hideedge, snap, tbl, prob). На проект делятся **оба набора**: у проекта `AWPCA` — ровно нынешние ключи, у + остальных — те же ключи с суффиксом `@<метка>` (`graph-pos:board@STOCKS_DRRK`, `graph-crit@STOCKS_DRRK`); общие для + всего приложения настройки вне доски (если есть) не делятся. Загрузка CSV/JSON и `applyJsonSettings` пишут в ключи + текущего проекта. Так раскладка AWPCA переживает выкатку, а проекты не смешиваются. Фильтр спринтов: `hasSprint` в модели из хаба — есть ли хотя бы одна задача со спринтом; у + канбан-проекта фильтра спринтов нет («колонки «Спринт» нет — показаны все задачи», как у 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.** Обоснование: базу мигрирует любой процесс новой версии (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` не + переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске. diff --git a/openspec/changes/projects/proposal.md b/openspec/changes/projects/proposal.md new file mode 100644 index 0000000..e250c70 --- /dev/null +++ b/openspec/changes/projects/proposal.md @@ -0,0 +1,57 @@ +## Why + +Владелец работает в одном Jira-проекте AURORA и разграничивает свои проекты метками. Сейчас мост знает один такой +проект — AWPCA (АРМ КА): базовая JQL `project = AURORA AND labels = AWPCA` и одна доска `board_id = 4216` зашиты в +настройки. Нужен второй проект — STOCKS_DRRK — со своей доской Jira (возможно, канбан без спринтов) и своим +репозиторием, откуда Claude ведёт его задачи через MCP. Дальше проектов может стать больше. + +Решения владельца 19.10.2026 (brainstorming, jira-hub#61): +- **полноценный вариант**: проект — сущность хаба, а не фильтр поверх общей JQL; +- у каждого проекта **своя доска Jira**, у неё может не быть спринтов (канбан); +- **сессия Claude в репозитории проекта видит и меняет только задачи своего проекта**; связи между проектами не нужны; +- **веб-приложение одно**, проект переключается выпадающим списком в шапке (вариант A из макетов), в его конце — + «+ Новый проект…»; +- проект заводит **человек из веб-приложения или Claude через MCP**; Claude затем сам прописывает в репозитории, с каким + проектом работает его сессия; +- **обновление из Jira — одним запросом по всем проектам**: метки всех проектов собираются в одну JQL; так и для + автообновления, и для кнопки «Обновить из Jira», и для `refresh` из MCP; +- **пока только создание проекта**: переименования и удаления нет. + +## What Changes + +- **Проекты в хабе.** Новая таблица `project` (схема v4): метка Jira (ключ, не меняется), название, номер доски Jira + (может отсутствовать), тип доски (`scrum` / `kanban`, определяет агент). Маршруты `GET /projects`, `POST /projects`. + Миграция заводит проект AWPCA «АРМ КА» с доской 4216 из нынешних настроек. +- **Базовая JQL собирается из проектов** — `project = AND labels in (<метки всех проектов>)`; настройки + `base_jql` и `board_id` уходят из `setting`. Создание проекта ставит полный `refresh`. +- **Разрез по проекту в API.** `GET /board?project=` и `GET /issues?project=` отдают задачи проекта и их соседей + (задачи других меток, связанные с ними, и их эпики) с признаком `neighbor` относительно проекта. +- **Пакет проекта.** `POST /batches` принимает `project`: хаб добавляет метку проекта в `ensure_issue`, запрещает чужие + метки проектов и команды над задачами вне проекта; пакет помнит свой проект (`batch.project`). +- **Спринты по доске проекта.** `ensure_sprint` получает `board_id` доски проекта задачи от хаба; на канбан-доске и без + доски — 422 при постановке. `refresh` получает от хаба список досок проектов; агент собирает спринты со всех + scrum-досок и сообщает тип досок, тип которых хабу ещё не известен. +- **MCP с привязкой к проекту.** `jira-hub mcp --project <метка>`: инструменты задач и пакетов работают в разрезе + проекта. Без привязки доступны только список и создание проектов и служебные инструменты. Новые инструменты + `projects`, `project_create` (ответ — готовый фрагмент `.mcp.json` для репозитория проекта). +- **Веб-приложение.** Выпадающий список проектов в шапке, форма «Новый проект», проект в адресе (`?project=`) и в + памяти браузера; доска и список задач — по выбранному проекту; раскладка доски хранится отдельно для каждого + проекта, раскладка AWPCA сохраняется. +- **Агент v12** (отдельный PR в jira-agent): доски из параметров команд вместо настройки `board_id`, тип доски. + +## Non-Goals + +- Переименование, удаление проекта, смена его доски — позже. +- Свои для каждого проекта словари (`kind_labels`, `link_types`, `transitions_map`, `issue_types`, `closed_statuses`) — + Jira-проект один, словари общие. +- Проекты в других Jira-проектах (кроме `project_key` = AURORA). +- Отдельное автообновление и отдельная кнопка «обновить» для одного проекта. +- Перенос задачи из проекта в проект (смена метки проекта) командами в разрезе проекта. +- Смена проекта внутри запущенной сессии MCP (только перезапуском с другим `--project`). + +## Anti-Goals + +- Задача одного проекта получила метку другого (или осталась без метки своего) из-за того, что сессия Claude не знала, + в каком она проекте. +- AWPCA после выкатки потерял задачи, заметки, раскладку доски или историю синхронизаций. +- Сессия в репозитории одного проекта по умолчанию видит и меняет задачи другого. diff --git a/openspec/changes/projects/specs/agent/spec.md b/openspec/changes/projects/specs/agent/spec.md new file mode 100644 index 0000000..c3332a2 --- /dev/null +++ b/openspec/changes/projects/specs/agent/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Доски проектов +Агент (с v12) SHALL брать доски Jira из параметров команд, а не из настроек: `ensure_sprint` — из `board_id` команды, +`refresh` — из `boards` (`[{id, type}]`). В `refresh` для каждой доски с `type = null` агент SHALL запросить +`GET /rest/agile/1.0/board/{id}` и вернуть в результате команды `boards: {"": "scrum" | "kanban" | null}` (`null` — +доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена +спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа +`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Команда без `board_id`/`boards` +(хаб до проектов) SHALL выполняться по `settings.board_id`, как в v11. `ensure_sprint` с `board_id` канбан-доски +агент не получает (хаб отказывает при постановке); если Jira всё же ответила, что у доски нет спринтов, — `failed` с +текстом Jira. + +#### Scenario: Две доски, одна новая +- **WHEN** `refresh {jql: …, boards: [{id: 4216, type: "scrum"}, {id: 5120, type: null}]}`, доска 5120 в Jira — kanban +- **THEN** агент запрашивает `GET /board/5120` один раз, спринты читает только с доски 4216; результат содержит + `boards: {"5120": "kanban"}` + +#### Scenario: Доска не найдена +- **WHEN** `refresh` с `boards: [{id: 9999, type: null}]`, Jira на `GET /board/9999` ответила 404 +- **THEN** результат `refresh` — `done` с `boards: {"9999": null}`; в логе агента — доска 9999 и код ответа + +#### Scenario: Старый хаб +- **WHEN** агент v12, хаб без проектов, `refresh {jql: …}` без `boards`, `settings.board_id = 4216` +- **THEN** спринты читаются с доски 4216, результат без `boards` + +#### Scenario: Спринт по доске из команды +- **WHEN** `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2", board_id: 4216}` +- **THEN** агент ищет «Спринт 2» среди активных и будущих спринтов доски 4216 и ставит задачу в него + +## MODIFIED Requirements + +### Requirement: Состав и настройки агента +Агент SHALL состоять из файла `agent.py` (Python ≥ 3.9, только стандартная библиотека), запускателя `run.py` (те же +ограничения) и файла `agent.json`: `hub_url`, `hub_token`, `jira.base_url`, `jira.auth` (`basic` с `user`/`password` +или `bearer` с `token`), `verify_ssl`, `ca_bundle`, `timeout`, `poll_seconds`, необязательный `auto_update` (по +умолчанию `true`). `hub_url` вида `http://…` или `https://…` SHALL означать HTTP-транспорт, вида +`folder:<абсолютный путь>` — транспорт через общую папку. Всё остальное (id полей, типы, карта переходов, типы связей, +темп) агент SHALL получать от хаба при старте и при изменении `settings_version`; доски Jira — в параметрах команд +(требование «Доски проектов»). Учётные данные Jira MUST NOT отправляться в хаб и MUST NOT попадать в журнал, логи и общую +папку. Команды `agent.py`: `run` (по умолчанию), `check`, `selftest`, `prune`. + +#### Scenario: Проверка без записи +- **WHEN** `python3 agent.py check` +- **THEN** агент печатает доступность хаба (версия настроек) и Jira (`GET /myself`), в Jira ничего не пишет + +#### Scenario: Проверка через папку +- **WHEN** `hub_url = "folder:/Volumes/My Shared Files/RSHB/jira-bridge"` и реле хаба работает +- **THEN** `check` печатает «хаб: доступен через папку /Volumes/My Shared Files/RSHB/jira-bridge, настройки v, проект + »; если каталога нет или реле не ответило за 10 с — строка с «НЕДОСТУПЕН» и причиной, код выхода 1 + +#### Scenario: Самопроверка новой версии +- **WHEN** `python3 agent.py selftest`, хаб и Jira доступны +- **THEN** печатается «jira-agent v» и строки `check`; код 0; блокировка журнала не берётся, в Jira ничего не пишется; + если хаб или Jira недоступны — код 3 diff --git a/openspec/changes/projects/specs/commands/spec.md b/openspec/changes/projects/specs/commands/spec.md new file mode 100644 index 0000000..7115e14 --- /dev/null +++ b/openspec/changes/projects/specs/commands/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Пакет проекта +Пакет, поставленный с `project = P` (см. `hub-api`), SHALL проходить при постановке, до записи, проверки проекта; +любая неудача — 422 с ключом задачи и причиной, пакет не создаётся: +- `P` нет среди проектов — 422; +- `ensure_issue`: если в `labels` нет P, хаб SHALL дописать P в конец `labels` (в базе параметры хранятся уже с ней; + без `labels` — `labels = [P]`); метка другого проекта в `labels` — 422; +- `ensure_fields`: P в `labels_remove` или метка другого проекта в `labels_add` — 422; +- `ensure_fields`, `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment`: `key` SHALL быть временным + ключом `ensure_issue` этого пакета или задачей зеркала с меткой P, иначе 422 « не в проекте P»; +- `ensure_link`: хотя бы один из `source`, `target` — временный ключ этого пакета или задача зеркала с меткой P; +- `refresh` — без проверок проекта. +«Метка другого проекта» и «P в `labels_remove`» SHALL сравниваться без учёта регистра; принадлежность задачи проекту +(метка P у задачи зеркала) — точным совпадением, как в разрезе проекта. Пакет без `project` эти проверки не проходит, +метки хаб в нём не дописывает. + +#### Scenario: Метка проекта дописана +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {temp_key: new:1, summary: "Сверка остатков", labels: ["kind-dev"]}` +- **THEN** пакет создан; в параметрах команды `labels = ["kind-dev", "STOCKS_DRRK"]` + +#### Scenario: Чужая метка проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_issue {…, labels: ["awpca"]}`, есть проект `AWPCA` +- **THEN** 422 «метка другого проекта: awpca», пакет не создан + +#### Scenario: Задача другого проекта +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_fields {key: AURORA-1538, duedate: …}`, у AURORA-1538 метка только `AWPCA` +- **THEN** 422 «AURORA-1538 не в проекте STOCKS_DRRK» + +#### Scenario: Связь с соседом +- **WHEN** пакет с `project = STOCKS_DRRK`, `ensure_link {source: AURORA-1 (метка STOCKS_DRRK), target: AURORA-1538 (метка AWPCA), …}` +- **THEN** пакет создан + +## MODIFIED Requirements + +### Requirement: Команда — желаемый результат на одной задаче +Каждая команда SHALL описывать желаемое состояние одной задачи Jira и выполняться агентом как «прочитать → сравнить → +записать только разницу». Повторное выполнение любой команды с тем же результатом в Jira SHALL завершаться `done` +без записи. Типы команд и их контракт: + +| Тип | Параметры | Что читает | Что пишет | +|---|---|---|---| +| `ensure_issue` | `temp_key` (`new:`), `issue_type` (`task`/`epic`/`bug`), `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels`, `epic_key`, `assignee` | поиск `project = AND reporter = currentUser() AND created >= -7d`, точное сравнение `summary` | при отсутствии — `POST /issue` с типом из `issue_types.` (для эпика — с `epic_name_field_id`); результат `key` | +| `ensure_fields` | `key`, любое подмножество: `summary`, `description`, `priority`, `duedate`, `planned_start`, `planned_end`, `labels_add`, `labels_remove`, `epic_key`, `assignee` | `GET /issue/{key}` | `PUT /issue/{key}` только с отличающимися полями; эпик — по `epic_link.mode` | +| `ensure_comment` | `key`, `marker`, `body` | `GET /issue/{key}/comment` | комментарий с `marker` в начале: `PUT` если текст отличается, `POST` если нет | +| `ensure_status` | `key`, `target` (имя статуса или список-путь) | `GET /issue/{key}` (статус), `GET /issue/{key}/transitions` на каждом шаге | `POST /transitions` по шагам пути; при отсутствии перехода — `failed` с перечнем доступных | +| `ensure_link` | `source`, `target`, `link_type` (логическое имя из `link_types`), `replace` (bool) | `GET /issue/{source}` (`issuelinks`) | нет нужной и нет конфликта — `POST /issueLink`; конфликт (другой тип из `link_types` или обратное направление) и `replace` — `DELETE` лишней, затем `POST`; конфликт без `replace` — `failed` с описанием | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); `board_id` хаб дописывает при постановке (от клиента — 422) | `GET /issue/{key}` (поле спринта), список спринтов доски `board_id` (активные и будущие) | `POST /rest/agile/1.0/sprint/{id}/issue` или `POST /rest/agile/1.0/backlog/issue` | +| `ensure_attachment` | `key`, `file` (sha256 файла в хранилище хаба, 64 hex-символа), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче | `GET /issue/{key}?fields=attachment` | вложения с тем же `filename` и размером нет — файл у хаба (`GET /agent/files/{file}`), затем `POST /issue/{key}/attachments` (multipart); есть — ничего; результат `attachment_id` | +| `refresh` | `keys` (список) или `jql`; `boards` (`[{id, type}]` — доски проектов, `type` — `scrum`, `kanban` или `null`) хаб дописывает при выдаче | `search` с полями зеркала, по 100 на страницу; плюс дочитывание соседей по `issuelinks`; доски с неизвестным типом — `GET /rest/agile/1.0/board/{id}` | ничего в Jira; состояние уходит в хаб; результат — `boards: {"": "scrum" | "kanban" | null}` для досок, чей тип агент запрашивал | + +Доску для `ensure_sprint` хаб SHALL определять при постановке: проект задачи — проекты, чьи метки есть у задачи в +зеркале (для временного ключа — в `labels` её `ensure_issue` в пакете, после дописывания метки проекта); `board_id` — +доска этого проекта. 422 при постановке: задача ни в одном проекте; в нескольких проектах с разными досками; у проекта +нет доски; тип доски `kanban` («у проекта P канбан-доска, спринтов нет»); тип доски ещё не известен («тип доски проекта P +ещё не известен — дождитесь обновления из Jira»). `boards` у `refresh` хаб SHALL дописывать при каждой выдаче (в базе +не хранится): все доски проектов без повторов. По результату `refresh` хаб SHALL записать присланные типы `scrum` и +`kanban` в проекты с этой доской; `null` тип не меняет. + +Семантика `ensure_link`: `source` блокирует/ссылается на `target`; агент в `POST /issueLink` SHALL ставить `source` в +`inwardIssue`, `target` в `outwardIssue` (проверено на Jira банка 11.10.2026). + +Текст описания (`ensure_fields`) и тело комментария (`ensure_comment`) агент SHALL сравнивать с текущим значением в Jira +без различия переводов строк `\r\n` и `\n`; записывается текст команды как есть. Jira хранит присланный текст, а правленный +в веб-интерфейсе — с `\r\n` (проверено на Jira банка 13.10.2026). + +#### Scenario: Повтор уже выполненной команды +- **WHEN** `ensure_fields {key: AURORA-1533, duedate: 2026-10-23}` выполняется, а в Jira срок уже 23.10 +- **THEN** агент не делает `PUT`, отчёт `done`, `jira_calls = 1` + +#### Scenario: Описание отличается только переводами строк +- **WHEN** `ensure_fields {key: X, description: "а\nб"}`, а в Jira описание X — `"а\r\nб"` +- **THEN** агент не делает `PUT`, отчёт `done` с `changed = []` + +#### Scenario: Замена связи +- **WHEN** `ensure_link {source: AURORA-1537, target: AURORA-1538, link_type: "связанные", replace: true}` и в Jira между + ними «блокирует начало» 1537→1538 +- **THEN** `DELETE` старой связи, пауза по политике, `POST` новой; отчёт `done` с обеими операциями + +#### Scenario: Связь-конфликт без replace +- **WHEN** та же команда с `replace: false` +- **THEN** `failed`, в `error` найденная связь, Jira не изменена + +#### Scenario: Переход по пути +- **WHEN** `ensure_status {key: X, target: ["Разработка", "Готово"]}` и X в «Ожидание разработки» +- **THEN** два `POST /transitions` с паузой между ними; если X уже «Готово» — `done` без записи + +#### Scenario: Создание ошибки +- **WHEN** `ensure_issue {temp_key: new:1, issue_type: bug, summary: "ОСВ 60 не распознана", labels: [AWPCA]}`, в настройках + `issue_types.bug = {name: "Ошибка"}` без `id` +- **THEN** агент создаёт задачу с `issuetype = {name: "Ошибка"}`; отчёт `done` с ключом + +#### Scenario: Вложение уже есть +- **WHEN** `ensure_attachment {key: X, file: , filename: "ОСВ 60.xlsx"}`, у X в Jira есть вложение «ОСВ 60.xlsx» того + же размера +- **THEN** агент не скачивает файл у хаба и не загружает его в Jira; отчёт `done` с `attachment_id` найденного, `jira_calls = 1` + +#### Scenario: Вложение с тем же именем, другого размера +- **WHEN** та же команда, у X есть «ОСВ 60.xlsx» другого размера +- **THEN** агент загружает файл вторым вложением с тем же именем (Jira это допускает); отчёт `done` с новым `attachment_id` + +#### Scenario: Создание с проверкой дубля +- **WHEN** `ensure_issue` повторяется после падения агента, а задача с той же темой от нас уже создана час назад +- **THEN** агент не создаёт вторую, отчёт `done` с ключом найденной + +#### Scenario: Спринт задачи проекта +- **WHEN** у AURORA-1538 метка `AWPCA`, проект `AWPCA` с доской 4216 типа `scrum`; `ensure_sprint {key: AURORA-1538, sprint: "Спринт 2"}` +- **THEN** пакет создан, в параметрах команды `board_id = 4216` + +#### Scenario: Спринт на канбане +- **WHEN** у AURORA-1 метка `STOCKS_DRRK`, доска проекта — `kanban`; `ensure_sprint {key: AURORA-1, sprint: "Спринт 2"}` +- **THEN** 422 «у проекта STOCKS_DRRK канбан-доска, спринтов нет» + +#### Scenario: Тип доски определён +- **WHEN** проект `STOCKS_DRRK` с доской 5120 и `board_type = null`; выдаётся `refresh`, агент вернул `boards: {"5120": "kanban"}` +- **THEN** при выдаче в параметрах `boards` есть `{id: 5120, type: null}`; после отчёта у проекта `board_type = kanban` diff --git a/openspec/changes/projects/specs/data-model/spec.md b/openspec/changes/projects/specs/data-model/spec.md new file mode 100644 index 0000000..9503eaf --- /dev/null +++ b/openspec/changes/projects/specs/data-model/spec.md @@ -0,0 +1,147 @@ +## 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 символов без пробельных символов, запятых, +кавычек и скобок; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть +непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL +записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания +хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: +`project = AND labels in (<метки проектов по created_at, затем label через ", ">)`; без проектов базовой JQL +нет (`null`). + +Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без +метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в +зеркале. Признак `neighbor` в выдаче в разрезе проекта SHALL считаться относительно P: `false` у своих, `true` у соседей. + +#### Scenario: Базовая JQL из двух проектов +- **WHEN** заведены проекты `AWPCA` (раньше) и `STOCKS_DRRK`, `project_key = AURORA` +- **THEN** базовая JQL — `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` + +#### Scenario: Свои и соседи +- **WHEN** у AURORA-1 метка `STOCKS_DRRK` и эпик AURORA-9 (метка `AWPCA`), связь AURORA-1 → AURORA-2 (метка `AWPCA`), + у AURORA-3 метка `AWPCA` без связей с проектом `STOCKS_DRRK` +- **THEN** в разрезе `STOCKS_DRRK` свои — AURORA-1, соседи — AURORA-2 и AURORA-9; AURORA-3 в разрез не входит + +#### Scenario: Метка с пробелом +- **WHEN** создаётся проект с меткой `STOCKS DRRK` +- **THEN** отказ с указанием поля `label`, проекта нет + +#### Scenario: Та же метка другим регистром +- **WHEN** есть проект `AWPCA`, создаётся проект `awpca` +- **THEN** отказ «проект с такой меткой уже есть», проект один + +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_note`, `batch`, +`command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 4). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени) и времени +комментариев (строки Jira как есть). + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version = 4` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 4` + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in (AWPCA)`; задачи, заметки, + пакеты на месте; `schema_version = 4` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in (AWPCA)`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Пакеты и команды +Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`, `project` (метка +проекта, если пакет поставлен в разрезе проекта, иначе `NULL`). +Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` +(`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи), +`deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`, +`rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). +Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе — +«выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»; +все терминальные без отмены — «завершён». + +#### Scenario: Статус пакета +- **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` +- **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` + +#### Scenario: Отменённый пакет с выполненной командой +- **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2 +- **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled` + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование +«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме +передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции); +`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `jira-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `jira-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт + +#### Scenario: Запись базовой JQL +- **WHEN** `PUT /settings {base_jql: "project = AURORA"}` +- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился + +#### Scenario: Создание проекта не трогает версию настроек +- **WHEN** создан проект `STOCKS_DRRK` +- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками diff --git a/openspec/changes/projects/specs/hub-api/spec.md b/openspec/changes/projects/specs/hub-api/spec.md new file mode 100644 index 0000000..d5477a7 --- /dev/null +++ b/openspec/changes/projects/specs/hub-api/spec.md @@ -0,0 +1,185 @@ +## ADDED Requirements + +### Requirement: Маршруты проектов +Хаб SHALL предоставлять под клиентским токеном: +- `GET /projects` → список `{label, name, board_id, board_type, created_at, issues_count}` по `created_at`, затем `label`; + `issues_count` — задачи зеркала с меткой проекта. +- `POST /projects` `{label, name, board_id?, author?}` → 201 `{project, batch_id}`: в одной транзакции проект + записывается и ставится пакет «Новый проект <метка>» из одного `refresh` по базовой JQL, уже включающей новую метку; + автор пакета — `author` (по умолчанию `api`). Неверные поля (см. `data-model`, «Проекты») — 422 с указанием поля; + метка, совпадающая с существующей без учёта регистра, — 409. При ошибке ни проекта, ни пакета нет. + +#### Scenario: Новый проект +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` +- **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`; в очереди пакет автора `viewer` + с `refresh {jql: "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"}` + +#### Scenario: Проект без доски +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` +- **THEN** 201, `board_id = null` + +#### Scenario: Повтор метки +- **WHEN** `POST /projects {label: "AWPCA", name: "…"}`, проект `AWPCA` есть +- **THEN** 409, пакет не поставлен + +#### Scenario: Без названия +- **WHEN** `POST /projects {label: "STOCKS_DRRK", name: " "}` +- **THEN** 422 с указанием поля `name` + +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `project` (разрез по проекту, см. `data-model`, «Проекты»: свои и соседи, `neighbor` + относительно проекта; неизвестный проект — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, + `epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку + `closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `fact_status` + и `work_kind` из заметок; сокращение до карточки — дело интерфейса. Без `project` — всё зеркало, `neighbor` — признак + зеркала (вне базовой JQL). +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней, + `comments: [{comment_id, author, created, updated, body}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию). +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела). +- `POST /batches` `{title, author, project?, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, + неверных параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более + `ensure_issue` с одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся + темы), по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен, доска для + `ensure_sprint`), а также по проверкам пакета проекта (см. `commands`), если передан `project`. +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=&project=` (с `project` — только пакеты этого проекта), `GET /batches/{id}` (с командами и + `project`), `POST /batches/{id}/cancel` — отменяет `queued` команды пакета; ответ — вид пакета с полем `still_claimed`: + список `{id, type}` команд пакета в `claimed` (они будут выполнены агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` `{author?, project?}` — новая команда с теми же параметрами в новом пакете «Повтор + команды N»; хаб снимает с параметров то, что дописывает сам (`board_id` у `ensure_sprint`), и проверяет команду + заново. `project` передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не + передан — новый пакет получает проект исходного и проходит его проверки. +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`; `full` — по базовой JQL из проектов, без + проектов — 422 «нет проектов». +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Список проекта +- **WHEN** свои задачи `STOCKS_DRRK` — AURORA-1 и AURORA-4, сосед — AURORA-2; `GET /issues?project=STOCKS_DRRK` +- **THEN** три задачи, у AURORA-2 `neighbor = true`; с `neighbor=1` — только AURORA-2; задач только с меткой `AWPCA` нет + +#### Scenario: Неизвестный проект +- **WHEN** `GET /issues?project=NOPE` +- **THEN** 404 + +#### Scenario: Пакеты проекта +- **WHEN** пакет 10 поставлен с `project = STOCKS_DRRK`, пакет 11 — без проекта; `GET /batches?project=STOCKS_DRRK` +- **THEN** в ответе только пакет 10 + +#### Scenario: Повтор чужой команды +- **WHEN** команда 40 из пакета проекта `AWPCA`; `POST /commands/40/retry {project: "STOCKS_DRRK"}` +- **THEN** 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан + +#### Scenario: Повтор ensure_sprint +- **WHEN** `ensure_sprint` 41 failed, в её `params` `board_id = 4216`; `POST /commands/41/retry` +- **THEN** новый пакет создан, `board_id` в новой команде вычислен хабом заново + +#### Scenario: Полный прогон без проектов +- **WHEN** проектов нет, `POST /refresh {full: true}` +- **THEN** 422 «нет проектов», пакет не поставлен + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +#### Scenario: Без закрытых +- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без + статуса; `GET /issues?hide_closed=1` +- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре + +### Requirement: Автообновление зеркала +Если `auto_refresh_minutes > 0`, агент на связи и есть хотя бы один проект, хаб SHALL поставить пакет «Автообновление» +из `refresh {jql: <базовая JQL из проектов>}`, когда в очереди нет команд в `queued` и `claimed` и последний `sync_run` +начат раньше, чем `auto_refresh_minutes` назад (или `sync_run` ещё нет). + +#### Scenario: Очередь занята +- **WHEN** интервал прошёл, но в очереди есть `queued` команды +- **THEN** автообновление не ставится до опустошения очереди + +#### Scenario: Есть выданная команда без отчёта +- **WHEN** интервал прошёл, `queued` нет, одна команда в `claimed` +- **THEN** автообновление не ставится; команда довыдаётся при следующем `claim` + +#### Scenario: Недавнее обновление +- **WHEN** очередь пуста, `auto_refresh_minutes = 60`, последний `sync_run` начат 20 минут назад +- **THEN** автообновление не ставится + +#### Scenario: Два проекта +- **WHEN** интервал прошёл, очередь пуста, проекты `AWPCA` и `STOCKS_DRRK` +- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` + +### Requirement: Снимок доски +Хаб SHALL предоставлять `GET /board?project=` под клиентским токеном — согласованный снимок зеркала для доски связей, +прочитанный в одной транзакции: +- `generated_at` — момент снимка (UTC); +- `last_sync` — последний завершённый полный прогон (`refresh` по текущей базовой JQL из проектов): + `{started_at, finished_at, issues_count}` или `null`, если такого не было; общий для всех проектов; +- `issues` — с `project` — свои задачи проекта и его соседи (см. `data-model`, «Проекты»), `neighbor` относительно + проекта; без `project` — все задачи зеркала, включая эпики и соседей, `neighbor` — признак зеркала. Поля: `key`, + `summary`, `issue_type`, `status`, `priority`, `assignee`, `sprint`, `duedate`, `planned_start`, `planned_end`, + `epic_key`, `labels` (список), `neighbor` (bool), `fact_status`, `work_kind` (из заметок, `null` если заметки нет); + порядок — по ключу; +- `links` — с `project` — связи, у которых хотя бы один конец — своя задача проекта; без `project` — все связи + зеркала: `{source, target, link_type}`, `link_type` — имя типа в Jira, направление как в `issue_link` (`source` + блокирует/ссылается на `target`). +Неизвестный проект — 404. Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело +интерфейса; хаб отдаёт данные зеркала без преобразований. + +#### Scenario: Снимок с задачами и связью +- **WHEN** в зеркале эпик AURORA-1351, задачи AURORA-1620 (метка `kind-dev`, эпик AURORA-1351) и AURORA-1621, связь + `AURORA-1620 → AURORA-1621` типа `ASUPR_Блокер_Начала`, заметка `work_kind = "разработка"` у AURORA-1620 +- **THEN** `GET /board` отдаёт три задачи по ключу, у AURORA-1620 `labels = ["kind-dev"]`, `epic_key = "AURORA-1351"`, + `work_kind = "разработка"`; в `links` — `{source: "AURORA-1620", target: "AURORA-1621", link_type: "ASUPR_Блокер_Начала"}` + +#### Scenario: Снимок проекта +- **WHEN** AURORA-1 (метка `STOCKS_DRRK`) связана с AURORA-2 (метка `AWPCA`), AURORA-2 связана с AURORA-3 (метка `AWPCA`); + `GET /board?project=STOCKS_DRRK` +- **THEN** `issues` — AURORA-1 (`neighbor = false`) и AURORA-2 (`neighbor = true`); `links` — только AURORA-1 ↔ AURORA-2 + +#### Scenario: Последний полный прогон +- **WHEN** в журнале синхронизаций полный прогон, завершённый в 10:00, и после него обновление по ключам в 11:00 +- **THEN** `last_sync.finished_at` — 10:00 + +#### Scenario: Без токена +- **WHEN** `GET /board` без токена или с агентским токеном +- **THEN** 401 diff --git a/openspec/changes/projects/specs/mcp/spec.md b/openspec/changes/projects/specs/mcp/spec.md new file mode 100644 index 0000000..7743a3a --- /dev/null +++ b/openspec/changes/projects/specs/mcp/spec.md @@ -0,0 +1,133 @@ +## 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; соседи — нет), иначе ответ « не в проекте P»; +- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого + проекта или пакета без проекта — отказ хаба); +- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения; +- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше. + +Без привязки инструменты `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать +«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`, +`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, +`batch_cancel` работают. + +#### Scenario: Сессия без проекта +- **WHEN** MCP запущен без `--project`, вызван `issues` +- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /issues` нет + +#### Scenario: Задачи своего проекта +- **WHEN** MCP запущен с `--project STOCKS_DRRK`, вызван `issues` +- **THEN** в таблице только свои задачи `STOCKS_DRRK` и их соседи + +#### Scenario: Создание задачи без метки +- **WHEN** сессия `STOCKS_DRRK`, `batch_create {commands: [ensure_issue {temp_key: new:1, summary: "…"}]}` без `labels` +- **THEN** пакет поставлен с `project = STOCKS_DRRK`, у `ensure_issue` `labels = ["STOCKS_DRRK"]` + +#### Scenario: Опечатка в проекте +- **WHEN** MCP запущен с `--project STOKS`, такого проекта нет; вызван `issues` +- **THEN** ответ «проект STOKS не заведён в хабе: …» + +## MODIFIED Requirements + +### Requirement: Сервер и регистрация +`jira-hub mcp [--project <метка>]` SHALL запускать MCP-сервер по stdio (пакет `mcp`), ходящий в HTTP API хаба клиентским +токеном из настроек. Общая регистрация — в user scope Claude Code как `jira-hub` (`uv run --project <путь> jira-hub mcp`), +без проекта. Репозиторий проекта SHALL регистрировать `jira-hub` в своём `.mcp.json` (project scope) с +`--project <метка>`; эта запись перекрывает общую. Если хаб недоступен, каждый инструмент SHALL вернуть текст «хаб не +запущен: jira-hub serve», а не исключение. + +#### Scenario: Хаб выключен +- **WHEN** вызван `issues` при остановленном хабе +- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет» | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "jira-hub", "mcp", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» diff --git a/openspec/changes/projects/specs/migration/spec.md b/openspec/changes/projects/specs/migration/spec.md new file mode 100644 index 0000000..58715e1 --- /dev/null +++ b/openspec/changes/projects/specs/migration/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Инициализация настроек +`jira-hub init --config ` SHALL перенести в `setting`: `project_key`, типы задач, +`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`, +`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`; сгенерировать два токена; завести проект +`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`, +`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента). + +#### Scenario: Повторный init +- **WHEN** `init` вызван на уже инициализированной базе +- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется + +#### Scenario: Первый init +- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216` +- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql` и `board_id` diff --git a/openspec/changes/projects/specs/web-app/spec.md b/openspec/changes/projects/specs/web-app/spec.md new file mode 100644 index 0000000..b08bba5 --- /dev/null +++ b/openspec/changes/projects/specs/web-app/spec.md @@ -0,0 +1,98 @@ +## ADDED Requirements + +### Requirement: Проект в приложении +Доска и список задач SHALL показывать один проект — текущий. Текущий проект SHALL задаваться параметром `project` в +адресе `/app/board` и `/app/issues`; без параметра — последний выбранный в этом браузере (`localStorage`), если он есть +в `GET /projects`, иначе первый из `GET /projects`; выбранный проект записывается в адрес без новой записи истории. +Проекта из адреса нет в `GET /projects` — страница SHALL показывать «проект <метка> не найден» и выпадающий список +проектов вместо данных. Проектов нет — доска и список SHALL показывать приглашение создать проект («+ Новый проект…»). +Переходы «Доска» / «Задачи» SHALL сохранять текущий проект. Карточка задачи (`/app/issues/{key}`) от проекта не зависит. + +Доска SHALL загружать `GET /board?project=<текущий>`; «Обновить из Jira» — как прежде, `POST /refresh {full: true}`, +обновляет все проекты. Раскладка, дорожки и фильтры доски в `localStorage` SHALL храниться отдельно для каждого проекта; +у проекта `AWPCA` — на прежних ключах (раскладка, сделанная до проектов, сохраняется). Фильтр спринтов SHALL +показываться, только если хотя бы у одной задачи снимка есть спринт; иначе — «колонки «Спринт» нет — показаны все +задачи». + +Форма «Новый проект» (пункт «+ Новый проект…» выпадающего списка) 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`, ни у одной задачи снимка нет спринта +- **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`, в таблице задачи в статусе «Готово» -- 2.47.2 From 0397c287396c8919dbbd216f8f755d790361ff6b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 19:18:04 +0400 Subject: [PATCH 02/16] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD=20=D1=85=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=20=D0=B8=20=D1=83=D1=82=D0=BE=D1=87=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0:=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B-?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-19-projects-hub.md | 1047 +++++++++++++++++ openspec/changes/projects/design.md | 8 +- .../changes/projects/specs/commands/spec.md | 3 +- openspec/changes/projects/specs/mcp/spec.md | 3 +- .../changes/projects/specs/web-app/spec.md | 6 +- 5 files changed, 1059 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-19-projects-hub.md diff --git a/docs/superpowers/plans/2026-10-19-projects-hub.md b/docs/superpowers/plans/2026-10-19-projects-hub.md new file mode 100644 index 0000000..5001fe4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-projects-hub.md @@ -0,0 +1,1047 @@ +# Несколько проектов-меток в мосте (хаб) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** проект (метка Jira + своя доска) — сущность хаба: базовая JQL собирается из проектов, API, MCP и веб-приложение +работают в разрезе одного проекта, AWPCA после выкатки работает как раньше. + +**Architecture:** новый модуль `projects` (таблица `project`, схема v4, миграция v3→v4, вычисляемая базовая JQL, доски) +стоит рядом с `settings` и ниже `queue`/`api`. Разрез «свои + соседи + эпики своих» — одна SQL-выборка в `mirror` +для `GET /board` и `GET /issues`. Проверки пакета проекта и выбор доски для `ensure_sprint` — в `queue` при постановке; +`boards` для `refresh` — в `queue.deliver_params` при выдаче. MCP привязывается к проекту флагом `--project` и ходит +только через `client`. Веб-приложение держит текущий проект в адресе (`?project=`) и `localStorage`, раскладка доски +делится по проектам суффиксом ключей (у AWPCA — прежние ключи). + +**Tech Stack:** Python 3.13 (stdlib http, SQLite), pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite, +TanStack Query, react-router 8, Vitest, Testing Library, Playwright. + +**Spec:** `openspec/changes/projects/` — `proposal.md`, `design.md` (решения 1–15, «Decisions made without discussion» +1–21), `specs/{data-model,migration,hub-api,commands,mcp,web-app,agent}/spec.md`. Спека `agent` — только контракт для +хабовой стороны (агент v12 — отдельный план в `jira-agent`). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes`, ветка `feat/61-projects` +(aep/jira-hub#61). Все команды — из корня worktree; основной checkout `jira-hub` не трогать (guard-main). + +## Global Constraints + +- Схема базы — версия 4: таблица `project(label TEXT PK, name TEXT NOT NULL, board_id INTEGER, board_type TEXT, created_at TEXT NOT NULL)`, колонка `batch.project TEXT` (NULL — пакет без проекта). +- Метка: непустая, ≤ 100 символов, без пробельных символов, `,`, `"`, `'`, `(`, `)`; регистр хранится как введён; две метки, равные без учёта регистра (`str.casefold`), не существуют. Название — непустое после `strip()`, ≤ 100. `board_id` — `int > 0` (не `bool`) или `None`. `board_type` ∈ {`scrum`, `kanban`, `None`}. +- Базовая JQL: `project = AND labels in (<метки по created_at, затем label, через ", ">)`; без проектов — `None`. Из `setting.base_jql` новый код не читает нигде. +- `settings.DEFAULTS` без `base_jql` и `board_id`; `AGENT_KEYS` без `base_jql`, с `board_id` (запасная доска агента). Миграция v4 ключи `base_jql`/`board_id` в `setting` **не удаляет**. +- Создание проекта `settings_version` не поднимает. +- Принадлежность задачи проекту и разрез — точное совпадение метки (`json_each`); «метка другого проекта» и «P в `labels_remove`» — без учёта регистра; в `ensure_issue` пакета проекта метка, равная P без учёта регистра, заменяется на P. +- Соседи на доске — обычные карточки, как сейчас (отдельной отрисовки нет); `hasSprint` — по своим задачам снимка (`neighbor = false`). +- `notes_set` в привязанной сессии MCP — свои задачи и временные ключи `new:`. +- Автор пакетов: MCP — `claude`, веб-приложение — `viewer`, API без автора — `api`, автообновление — `hub`. +- MCP: `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`, ходит только через `client` (границы ARCHITECTURE.md). +- Веб-приложение: у проекта `AWPCA` ключи `localStorage` доски — ровно нынешние (`graph-<имя>:board`, `graph-<имя>`), у остальных — те же с суффиксом `@<метка>`. Golden-эталоны `web/src/board/__fixtures__/*.expected.json` не перегенерировать (см. Task 9 про `hub-empty`). +- Новых зависимостей (Python и npm) нет; `dangerouslySetInnerHTML` не появляется. +- Язык комментариев, сообщений об ошибках, подписей и коммитов — русский, идентификаторы — английские. Каждый коммит — с `(#61)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта); в задачах, где менялся фронт, — ещё `cd web && npm run e2e`. +- Любое решение исполнителя, не записанное в спеке или в этом плане, дописывается в `openspec/changes/projects/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). Находки ревью задачи n — маркеры `T.`. + +## Review Focus + +1. Живая база v3 с `base_jql` в неожиданной форме (`labels = "AWPCA"` в кавычках, хвост `ORDER BY key`, нестандартная JQL) — хаб стартует, миграция не падает: кавычки снимаются, неразобранная JQL даёт «проектов нет» и предупреждение в лог (тесты в Task 1). +2. Агент v11 (или v12 без запроса типов) присылает результат `refresh` без ключа `boards`, ключи `boards` — строки (`"5120"`), значение `null` или мусорный ключ — типы досок не меняются, отчёт принимается (тесты в Task 6). +3. Прямой адрес карточки `/app/issues/AURORA-9001` без `project`: выбор проекта не переписывает адрес и не закрывает окно; список под окном — по запомненному проекту (тест в Task 8). +4. MCP с `--project`, хаб выключен — ответ «хаб не запущен: jira-hub serve», а не «проект … не заведён» (тест в Task 7). +5. Повтор (`retry`) старой команды: `ensure_sprint`, поставленной до миграции без `board_id` и без проекта пакета, получает доску заново; повтор команды проектного пакета без `project` наследует проект и его проверки (тесты в Task 5 и Task 6). + +--- + +### Task 1: Модуль `projects`, схема v4 и миграция v3 → v4 + +**Модель исполнителя:** sonnet (интеграция: схема, цикл миграций в `db`, новый модуль; алгоритм разбора дан в плане). + +**Files:** +- Create: `src/jira_hub/projects.py` +- Modify: `src/jira_hub/db.py` (`SCHEMA`: таблица `project`, колонка `batch.project`; `SCHEMA_VERSION = 4`; `MIGRATIONS[3]`; цикл `migrate` выполняет и вызываемые шаги) +- Modify: `tests/conftest.py` (фикстура `add_project`) +- Test: `tests/test_projects.py` (новый), `tests/test_db.py` + +**Interfaces:** +- Consumes: `settings.get(conn, key)`, `db.now()`, `db.transaction(conn)`. +- Produces (`src/jira_hub/projects.py`): + - `LEGACY_LABEL = "AWPCA"`, `LEGACY_NAME = "АРМ КА"`, `MAX_LEN = 100`, `BOARD_TYPES = ("scrum", "kanban")`. + - `class ProjectError(ValueError)` с атрибутом `field: str` (`"label"`, `"name"`, `"board_id"`), `str(e)` = `": <причина>"`; `class ProjectExists(ProjectError)` (`field = "label"`, текст «проект с такой меткой уже есть: <метка>»). + - `validate(label, name, board_id) -> tuple[str, str, int | None]` — проверка по Global Constraints, возвращает `(label, name.strip(), board_id)`. + - `create(conn, label: str, name: str, board_id: int | None = None) -> dict` — проверка, уникальность без учёта регистра, вставка с `board_type = NULL`, `created_at = db.now()`; внутри `db.transaction` (вложенный вызов — savepoint); возвращает `get(conn, label)`. + - `get(conn, label: str) -> dict | None` — точное совпадение; поля `label, name, board_id, board_type, created_at`. + - `list_projects(conn) -> list[dict]` — те же поля + `issues_count` (задачи зеркала с меткой, точное совпадение через `json_each`), порядок `created_at, label`. + - `labels(conn) -> list[str]` — в том же порядке. + - `base_jql(conn) -> str | None` — `project_key` из `setting`, при отсутствии — `"AURORA"`. + - `boards(conn) -> list[dict]` — `[{"id": int, "type": str | None}]`, доски проектов без повторов (порядок первого проекта), `type` — первый известный тип среди проектов с этой доской; проекты без доски не входят. + - `record_board_types(conn, boards) -> int` — для пар `{"": "scrum" | "kanban"}` обновляет `board_type` всех проектов с `board_id = int(id)`; не-словарь, нечисловой ключ, значение не из `BOARD_TYPES` (в том числе `None`) — пропуск; возвращает число обновлённых проектов. + - `parse_legacy_labels(jql) -> list[str] | None`, `migrate_v3(conn) -> None` (алгоритм — Step 3). +- Produces (`tests/conftest.py`): фикстура `add_project` — функция `add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict` (через `projects.create`; `name=None` → `LEGACY_NAME` для AWPCA, иначе метка; `board_type` пишется `record_board_types`, если доска и тип заданы). + +- [ ] **Step 1: Write the failing tests** + +`tests/test_projects.py` (фикстура `conn` модуля: `open_db()` + `settings.ensure_defaults`): +```python +@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None]) +def test_bad_label(conn, label): + with pytest.raises(projects.ProjectError) as e: + projects.create(conn, label, "Название") + assert e.value.field == "label" + assert projects.list_projects(conn) == [] + +@pytest.mark.parametrize("name", ["", " ", "Я" * 101, None, 7]) +def test_bad_name(conn, name): ... # field == "name", проекта нет + +@pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5]) +def test_bad_board(conn, board): ... # field == "board_id" + +def test_create_and_list(conn): + a = projects.create(conn, "AWPCA", " АРМ КА ", 4216) + assert a == {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": a["created_at"]} + projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК") + mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}, {"key": "A-2", "labels": ["awpca", "x"]}], []) + got = projects.list_projects(conn) + assert [(p["label"], p["board_id"], p["issues_count"]) for p in got] == [("AWPCA", 4216, 1), ("STOCKS_DRRK", None, 0)] + +def test_order_created_then_label(conn): + projects.create(conn, "ZZZ", "z"); projects.create(conn, "AAA", "a"); projects.create(conn, "MMM", "m") + conn.execute("UPDATE project SET created_at = '2026-01-01T00:00:00+00:00' WHERE label IN ('AAA', 'MMM')") + conn.execute("UPDATE project SET created_at = '2026-01-02T00:00:00+00:00' WHERE label = 'ZZZ'") + assert projects.labels(conn) == ["AAA", "MMM", "ZZZ"] + +def test_same_label_other_case(conn): + projects.create(conn, "AWPCA", "АРМ КА") + with pytest.raises(projects.ProjectExists) as e: + projects.create(conn, "awpca", "…") + assert "уже есть" in str(e.value) and len(projects.list_projects(conn)) == 1 + +def test_base_jql(conn): + assert projects.base_jql(conn) is None + projects.create(conn, "AWPCA", "АРМ КА"); projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК") + assert projects.base_jql(conn) == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + settings.set_(conn, "project_key", "AUR") + assert projects.base_jql(conn).startswith("project = AUR AND") + +def test_boards_dedup_and_types(conn): + projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120) + projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d") + projects.record_board_types(conn, {"4216": "scrum"}) + assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской + +@pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}]) +def test_record_board_types_ignores(conn, boards): + projects.create(conn, "B", "b", 5120) + assert projects.record_board_types(conn, boards) == 0 + assert projects.get(conn, "B")["board_type"] is None + +@pytest.mark.parametrize("jql, labels", [ + ("project = AURORA AND labels = AWPCA", ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, STOCKS_DRRK)", ["AWPCA", "STOCKS_DRRK"]), + ("project = AURORA AND labels IN (A,B)", ["A", "B"]), + ('project = AURORA AND labels = "AWPCA"', ["AWPCA"]), + ("project = AURORA AND labels in (AWPCA, awpca)", ["AWPCA"]), + ("project = AURORA", None), + ("project = AURORA AND labels = AWPCA ORDER BY key", None), + ("project = AURORA AND labels in (A B)", None), + ("", None), (None, None), (5, None), +]) +def test_parse_legacy_labels(jql, labels): + assert projects.parse_legacy_labels(jql) == labels +``` + +`tests/test_db.py`: +- `test_connect_creates_schema`: `"project"` в таблицах, `"project"` в колонках `batch`, `schema_version == "4"`. +- `_v1_database` и `test_migrates_v2_with_backup` дополнительно делают `ALTER TABLE batch DROP COLUMN project` и `DROP TABLE project` перед записью старой версии; ожидаемая версия после миграции — `"4"`. +- Новый помощник `_v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=4216)`: `db.connect` + `settings.ensure_defaults`; записывает `base_jql`/`board_id` в `setting` напрямую (`INSERT OR REPLACE`, значения через `json.dumps`); пакет с `refresh {jql: base_jql}` в `done` + строка `sync_run(kind='jql', query=base_jql, finished_at, command_id)`; пакет с `refresh {jql: base_jql}` в `queued` и ещё один в `claimed`; `refresh {jql: "key = A-1"}` в `queued`; задача, заметка; затем `DROP COLUMN project`, `DROP TABLE project`, `schema_version = 3`. + +```python +def test_migrates_v3_creates_awpca(tmp_path): + path = tmp_path / "hub.sqlite" + _v3_database(path) + conn = db.connect(path) + new = "project = AURORA AND labels in (AWPCA)" + assert projects.list_projects(conn)[0] | {"created_at": None, "issues_count": None} == { + "label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None} + assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте + assert settings.get(conn, "board_id") == 4216 + assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new + jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")} + assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем + assert jqls["claimed"] == new + queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] + assert sorted(queued) == sorted([new, "key = A-1"]) + assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 + conn.close() + +def test_migrates_v3_two_labels_board_to_nobody(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels in (AWPCA, STOCKS_DRRK)") + conn = db.connect(tmp_path / "hub.sqlite") + got = {p["label"]: (p["name"], p["board_id"]) for p in projects.list_projects(conn)} + assert got == {"AWPCA": ("АРМ КА", None), "STOCKS_DRRK": ("STOCKS_DRRK", None)} + assert "4216" in caplog.text + conn.close() + +def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): + _v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels = AWPCA ORDER BY key") + conn = db.connect(tmp_path / "hub.sqlite") + assert projects.list_projects(conn) == [] + assert "ORDER BY key" in caplog.text + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + conn.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_projects.py tests/test_db.py -v` +Expected: FAIL (`ModuleNotFoundError: jira_hub.projects`, версия схемы `3`). + +- [ ] **Step 3: Implement `src/jira_hub/projects.py` and the schema** + +- `db.SCHEMA`: `CREATE TABLE IF NOT EXISTS project (...)` по Global Constraints; в `CREATE TABLE batch` — `project TEXT` последней колонкой. Индекс по `batch.project` не заводить (на базе v3 `SCHEMA` выполняется до `ALTER`). +- `db.MIGRATIONS[3] = ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects]`, где `_migrate_projects(conn)` делает `from . import projects` внутри функции (цикл импорта `projects → db`) и вызывает `projects.migrate_v3(conn)`; цикл в `migrate`: `step(conn) if callable(step) else conn.execute(step)`. +- Разбор и миграция (алгоритм не задаётся сигнатурой): + +```python +_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P\S+)|in\s*\((?P[^()]*)\))\s*$", re.IGNORECASE) + +def parse_legacy_labels(jql) -> list[str] | None: + m = _LEGACY.search(jql) if isinstance(jql, str) else None + if not m: + return None + out = [] + for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): + label = raw.strip().strip('"') + if _label_error(label): # та же проверка, что в validate + return None + if label.casefold() not in {x.casefold() for x in out}: + out.append(label) + return out or None + +def migrate_v3(conn) -> None: + old = settings.get(conn, "base_jql") + labels = parse_legacy_labels(old) + if labels is None: + log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old) + return + board = settings.get(conn, "board_id") + board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None + if board is not None and len(labels) > 1: + log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(labels)) + board = None + for label in labels: + create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board) + new = base_jql(conn) + conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old)) + conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' " + "AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old)) +``` + +- Остальные функции — по **Interfaces**; уникальность меток — сравнение `casefold()` в Python по `labels(conn)` (SQLite `lower()` не знает кириллицу). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный (остальной код ещё читает `setting.base_jql` по умолчанию — это меняет Task 2). + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/projects.py src/jira_hub/db.py tests/conftest.py tests/test_projects.py tests/test_db.py +git commit -m "Хаб: таблица project, схема v4 и миграция base_jql в проекты (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Базовая JQL из проектов везде; настройки и `init` + +**Модель исполнителя:** sonnet (интеграция: замена `base_jql` в `settings`, `queue`, `api`, `cli` и правка существующих тестов). + +**Files:** +- Modify: `src/jira_hub/settings.py` (`DEFAULTS`, `AGENT_KEYS`, `validate`, `init_from_config`) +- Modify: `src/jira_hub/projects.py` (`ensure_legacy`) +- Modify: `src/jira_hub/queue.py` (`is_full_refresh`, `maybe_auto_refresh`) +- Modify: `src/jira_hub/api.py` (`h_refresh`, `h_board`, `h_settings_get`) +- Modify: `src/jira_hub/cli.py` (`init` заводит AWPCA) +- Test: `tests/test_settings.py`, `tests/test_cli.py`, `tests/test_queue.py`, `tests/test_api.py`, `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `projects.base_jql`, `projects.create`, `projects.get`, `projects.LEGACY_*` (Task 1); фикстура `add_project`. +- Produces: + - `projects.ensure_legacy(conn, board_id: int | None) -> bool` — заводит `AWPCA` «АРМ КА» с доской, если проекта с меткой `awpca` (без учёта регистра) нет; `True` — заведён. Неверный `board_id` из конфига → проект без доски. + - `settings.validate("base_jql", …)` → `ValueError("базовая JQL собирается из проектов (GET /projects)")`; `settings.validate("board_id", …)` → `ValueError("доска Jira — свойство проекта (POST /projects)")`. + - `GET /settings` — все настройки + `base_jql` = `projects.base_jql(conn)` (перекрывает оставшийся ключ v3). + - `POST /refresh {full: true}` без проектов → 422 `bad_refresh` «нет проектов». + +- [ ] **Step 1: Write the failing tests** + +- `tests/test_settings.py`: `test_defaults_without_jql_and_board` — `"base_jql"`, `"board_id"` нет в `DEFAULTS` и в `get_all` новой базы; `"base_jql" not in AGENT_KEYS`, `"board_id" in AGENT_KEYS`, `for_agent(conn)["board_id"] is None`. `test_jql_and_board_not_writable` — `validate("base_jql", "project = AURORA")` и `validate("board_id", 1)` → `ValueError` с «собирается из проектов» / «свойство проекта». `test_init_from_config` — вернувшийся список ключей и таблица `setting` без `base_jql` и `board_id` (прежняя проверка `board_id == 4216` уходит в `test_cli`). +- `tests/test_cli.py` (`test_init_import_tokens`): после `init --config FIXTURE` — `projects.list_projects(conn)` = один `AWPCA` «АРМ КА», `board_id == 4216`; `settings.get(conn, "board_id") is None`; `init --config … --force` — проект один, доска та же. +- `tests/test_queue.py`: +```python +def test_auto_refresh_needs_projects(conn): + queue.claim(conn, agent_version=1, settings_version=1) + assert queue.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет + +def test_auto_refresh_two_projects(conn, add_project): # сценарий «Два проекта» + add_project(conn); add_project(conn, "STOCKS_DRRK", board_id=None) + queue.claim(conn, agent_version=1, settings_version=1) + b = queue.maybe_auto_refresh(conn) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} +``` + `test_auto_refresh_conditions`, `test_empty_full_refresh_keeps_neighbors`, `test_redelivered_full_refresh_marks_neighbors_once` заводят `add_project(conn)` и берут JQL из `projects.base_jql(conn)`. +- `tests/test_api.py`: `test_settings_put_rejects_jql_and_board` — `PUT /settings {base_jql: …}` → 422, `detail` содержит «базовая JQL собирается из проектов», `settings_version` прежний; то же для `{board_id: 12}` (прежний тест, писавший `board_id: 12`, переписать без него). `test_full_refresh_without_projects` — 422, `detail` «нет проектов», пакетов нет (сценарий «Полный прогон без проектов»). Тест на строке ~124 (`jql == "project = AURORA AND labels = AWPCA"`) — заводит AWPCA и ждёт `"project = AURORA AND labels in (AWPCA)"`. Тест на строке ~526 (`out["base_jql"]`) — без проектов `None`, после `add_project` — вычисленная строка. Тесты `last_sync` у `GET /board` и `_run_refresh(... {"full": True} ...)` заводят проект в фикстуре. +- `tests/test_mcp.py`: фикстура `live` заводит `add_project(conn)` (нужен `refresh(full=True)`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_settings.py tests/test_cli.py tests/test_queue.py tests/test_api.py -v` +Expected: FAIL (`base_jql` ещё в `DEFAULTS`, `init` не заводит проект, `full` берёт строку из настроек). + +- [ ] **Step 3: Implement** + +- `settings`: убрать `base_jql`, `board_id` из `DEFAULTS` и из `values` в `init_from_config`; `base_jql` убрать из `AGENT_KEYS` (агент v11/v12 его не читает); ветки `validate` для двух ключей — первыми. +- `queue.is_full_refresh`: сравнение с `projects.base_jql(conn)` (`None` → `False`). `queue.maybe_auto_refresh`: `jql = projects.base_jql(conn)`; `None` → `None` до остальных проверок. +- `api.h_refresh`: `full` → `projects.base_jql(conn)`, `None` → `ApiError(422, "bad_refresh", "нет проектов")`. `api.h_board`: `mirror.board(conn, projects.base_jql(conn))`. `api.h_settings_get`: `{**settings.get_all(conn), "base_jql": projects.base_jql(conn)}`; ответ `h_settings_put` — так же. +- `cli` `init`: после `settings.init_from_config` — `projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id"))`, строка в выводе «проект AWPCA «АРМ КА», доска » при создании. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. Затем `grep -n "base_jql" src/jira_hub/*.py` — `settings.get(conn, "base_jql")` есть только в `projects.migrate_v3`. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub tests +git commit -m "Хаб: базовая JQL из проектов, настройки без base_jql/board_id, init заводит AWPCA (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Маршруты `GET /projects` и `POST /projects` + +**Модель исполнителя:** haiku (готовый код: два обработчика по Interfaces, тесты в плане). + +**Files:** +- Modify: `src/jira_hub/api.py` (`h_projects_get`, `h_projects_post`, `ROUTES`) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.create`, `projects.list_projects`, `projects.base_jql`, `ProjectError`, `ProjectExists` (Task 1); `queue.create_batch(conn, title, author, cmds, files_dir=None)`. +- Produces: + - `GET /projects` → `200 {"projects": [{label, name, board_id, board_type, created_at, issues_count}]}`. + - `POST /projects {label, name, board_id?, author?}` → `201 {"project": {...как в списке без issues_count...}, "batch_id": int}`; пакет «Новый проект <метка>», автор `author or "api"`, одна команда `refresh {jql: <базовая JQL с новой меткой>}`, `batch.project = NULL`. Ошибки: `ProjectExists` → `409 project_exists`; `ProjectError` → `422 bad_project`, `detail = str(e)` (начинается с имени поля); `author` не строка → 422. Проект и пакет — в одной `db.transaction`. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_create_project(hub): # сценарий «Новый проект» + c = hub["client"] + conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) + assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None + got = c.get("/projects")["projects"] + assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 + b = c.get(f"/batches/{out['batch_id']}") + assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None + assert b["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} + assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» + assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + +def test_create_project_without_board(hub): ... # 201, board_id None, author пакета "api" + +@pytest.mark.parametrize("body, status, field", [ + ({"label": "AWPCA", "name": "…"}, 409, "label"), + ({"label": "awpca", "name": "…"}, 409, "label"), + ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), + ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), + ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), + ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), +]) +def test_create_project_errors(hub, body, status, field): + c = hub["client"] + c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) + before = len(c.get("/batches")["batches"]) + with pytest.raises(HubError) as e: + c.post("/projects", body) + assert e.value.status == status and (field is None or e.value.detail.startswith(field)) + assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before +``` +`test_auth_scopes`: `GET /projects` агентским токеном → 401. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_api.py -k project -v` +Expected: FAIL (404 на `/projects`). + +- [ ] **Step 3: Implement handlers** по **Interfaces**; маршруты `("GET", r"^/projects$", h_projects_get, "client")`, `("POST", r"^/projects$", h_projects_post, "client")`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/api.py tests/test_api.py +git commit -m "Хаб: маршруты GET/POST /projects, создание проекта ставит полный refresh (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Разрез по проекту в зеркале, `GET /board?project=`, `GET /issues?project=` + +**Модель исполнителя:** sonnet (интеграция: CTE дан в плане, встраивается в `board` и `list_issues` с фильтрами). + +**Files:** +- Modify: `src/jira_hub/mirror.py` (`SCOPE_CTE`, `board`, `list_issues`, `labels_of`) +- Modify: `src/jira_hub/api.py` (`ISSUE_FILTERS` + `project`, `h_issues`, `h_board`, `_project_param`) +- Test: `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.get` (Task 1), `projects.base_jql` (Task 2). +- Produces: + - `mirror.board(conn, base_jql, project: str | None = None) -> dict` — с `project`: `issues` — свои и соседи, `neighbor` относительно проекта; `links` — связи, у которых `source` или `target` — своя задача; `last_sync` — как без проекта. + - `mirror.list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=())` — `filters["project"]` включает разрез; фильтр `neighbor` — относительно проекта; остальные фильтры, сортировка и порции — поверх. + - `mirror.labels_of(conn, key: str) -> list[str] | None` — метки задачи зеркала, `None` — задачи нет (использует Task 5). + - `api._project_param(conn, q) -> str | None` — `q["project"]` или `None`; неизвестный проект → `ApiError(404, "not_found", "project <метка>")`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_mirror.py` (помощник `issue()` уже есть; `PROJ = "STOCKS_DRRK"`): +```python +def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта» + mirror.apply_state(conn, [ + issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"), + issue("AURORA-2", labels=["AWPCA"], epic_key=None), + issue("AURORA-3", labels=["AWPCA"], epic_key=None), + issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале + issue("AURORA-5", labels=["AWPCA"], epic_key=None), + issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)], + [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"}, + {"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"}, + {"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед + {"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале + neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе + +def test_board_project_scope(conn): + _scene(conn) + snap = mirror.board(conn, BASE, project=PROJ) + assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [ + ("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)] + assert {(l["source"], l["target"]) for l in snap["links"]} == { + ("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")} + assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало + +def test_list_issues_project(conn): # сценарий «Список проекта» + _scene(conn) + keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})] + assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"] + assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"] + assert keys(label="kind-dev") == ["AURORA-4"] + assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False + +def test_labels_of(conn): + _scene(conn) + assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] + assert mirror.labels_of(conn, "AURORA-404") is None +``` +`tests/test_api.py`: `test_board_and_issues_by_project` — после `POST /projects` (AWPCA, STOCKS_DRRK) и `/agent/state` со сценой `GET /board?project=STOCKS_DRRK` и `GET /issues?project=STOCKS_DRRK&neighbor=1` отдают ожидаемое; `last_sync` совпадает с ответом без `project`; `GET /issues?project=NOPE` и `GET /board?project=NOPE` → 404. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mirror.py tests/test_api.py -k "project or labels_of" -v` +Expected: FAIL (`board()` не принимает `project`). + +- [ ] **Step 3: Implement** + +Выборка разреза — один CTE для `board` и `list_issues` (единственный параметр — метка, первым в списке аргументов запроса): + +```sql +WITH own(key) AS ( + SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)), +near(key) AS ( + SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own) + UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own) + UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL), +scope(key, nb) AS ( + SELECT key, 0 FROM own + UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own)) +``` + +`board`: задачи — `JOIN scope s ON s.key = i.key`, `neighbor` = `s.nb`; связи — `WHERE source_key IN own OR target_key IN own` (тот же CTE). `list_issues`: при `project` — тот же `JOIN`, фильтр `neighbor` — по `s.nb`, в результате `neighbor = bool(s.nb)`. `api.h_board` / `api.h_issues`: `project = _project_param(conn, q)` до запроса к зеркалу. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/mirror.py src/jira_hub/api.py tests/test_mirror.py tests/test_api.py +git commit -m "Хаб: разрез по проекту — свои, соседи по связям и эпики своих в /board и /issues (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: Пакет проекта — проверки постановки, `batch.project`, `GET /batches?project=`, повтор + +**Модель исполнителя:** sonnet (интеграция: проверки постановки в `queue`, API и повтор). + +**Files:** +- Modify: `src/jira_hub/queue.py` (`create_batch`, `_check_project`, `list_batches`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_batches_post`, `h_batches_get`, `h_command_retry`) +- Test: `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.get`, `projects.labels` (Task 1); `mirror.labels_of` (Task 4). +- Produces: + - `queue.create_batch(conn, title, author, cmds, files_dir=None, project: str | None = None) -> dict` — порядок: `C.validate` → `_check_project` (если `project is not None`) → `_check_setup` → хвостовой `refresh` → вставка с `batch.project`. Ответ прежний (`id`, `estimate_seconds`, `count`). + - `queue._check_project(conn, cmds: list[dict], project: str) -> list[dict]` — новые словари команд (входные `params` не мутируются); `CommandError` с текстами: «команда {i}: проекта {P} нет», «команда {i}: метка другого проекта: {L}», «команда {i}: снять метку своего проекта {P} нельзя», «команда {i}: {key} не в проекте {P}», «команда {i}: связь {source} → {target} не касается проекта {P}». + - `queue.list_batches(conn, limit=20, project: str | None = None) -> list[dict]`; `batch_view` отдаёт `project`. + - `queue.retry_command(conn, command_id, author, files_dir=None, project: str | None = None) -> dict` — `project` передан и не равен `batch.project` исходной (в том числе `NULL`) → `CommandError(f"команда #{id} не из проекта {project}")`; не передан — новый пакет с проектом исходного. + - API: `POST /batches {…, project?}` (не строка → 422 `bad_batch`); `GET /batches?project=`; `POST /commands/{id}/retry {author?, project?}` (не строка → 422). + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py` (фикстуры `conn`, `add_project`; помощник `two(conn, add_project)` заводит `AWPCA` 4216 scrum и `STOCKS_DRRK` без доски и кладёт в зеркало `AURORA-1538` (`["AWPCA"]`) и `AURORA-1` (`["STOCKS_DRRK"]`)): +```python +S = "STOCKS_DRRK" +def new_issue(labels=None, temp="new:1"): + p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"} + return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}} + +def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана» + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["kind-dev"])], project=S) + view = queue.batch_view(conn, b["id"]) + assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S] + b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] + +def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P + two(conn, add_project) + b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"] + b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S) + assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P + +@pytest.mark.parametrize("cmd, msg", [ + (new_issue(["awpca"]), "метка другого проекта: awpca"), # «Чужая метка проекта» + (fields("AURORA-1538"), "AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта» + (fields("AURORA-1", labels_remove=["stocks_drrk"]), "снять метку своего проекта"), + (fields("AURORA-1", labels_add=["AWPCA"]), "метка другого проекта: AWPCA"), + ({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"), + ({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}}, "не касается проекта"), +]) +def test_project_batch_rejected(conn, add_project, cmd, msg): + two(conn, add_project) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [cmd], project=S) + assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 + +def test_project_batch_accepted(conn, add_project): + two(conn, add_project) + link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}} + queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом» + queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment", + "params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой + queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S) + +def test_unknown_project_batch(conn): ... # project="NOPE" → CommandError «проекта NOPE нет» +def test_batch_without_project_unchanged(conn, add_project): ... # labels не дописаны, batch.project None, чужие задачи проходят + +def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта» + two(conn, add_project) + a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S) + queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]] + assert len(queue.list_batches(conn)) == 2 + +def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды» + two(conn, add_project) + own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA") + loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) + cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"] + with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"): + queue.retry_command(conn, cid(own), "claude", project=S) + with pytest.raises(CommandError, match="не из проекта AWPCA"): + queue.retry_command(conn, cid(loose), "claude", project="AWPCA") + r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного + assert queue.batch_view(conn, r["id"])["project"] == "AWPCA" +``` +`tests/test_api.py`: `POST /batches {project: "STOCKS_DRRK", …}` с чужой задачей → 422, `detail` содержит «не в проекте»; `project: 5` → 422; `GET /batches?project=STOCKS_DRRK`; `POST /commands/{id}/retry {project: "STOCKS_DRRK"}` для команды пакета AWPCA → 422, пакет не создан. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "project or retry" -v` +Expected: FAIL (`create_batch() got an unexpected keyword argument 'project'`). + +- [ ] **Step 3: Implement** + +- `_check_project`: `projects.get(conn, P)` — нет → ошибка; `others = {l.casefold() for l in projects.labels(conn) if l != P}`; временные ключи — `temp_key` всех `ensure_issue` пакета; «своя» задача — временный ключ пакета или `P in (mirror.labels_of(conn, key) or [])`. `ensure_issue`: чужая метка (`casefold() in others`) — ошибка; метки, равные `P` без учёта регистра, но не точно, заменяются на `P` на своём месте (повторы `P` после замены убираются, остаётся первое вхождение); `P` после этого нет среди `labels` — дописать в конец (`labels` нет — `[P]`). `ensure_fields`: `P.casefold()` среди `labels_remove` (casefold) или чужая метка в `labels_add` — ошибка; затем проверка `key`. `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment` — проверка `key`; `ensure_link` — свой хотя бы один конец; `refresh` — без проверок. +- `create_batch` пишет `project` в `INSERT INTO batch`. `list_batches` — `WHERE project = ?` при `project`. `retry_command` — по **Interfaces** (проект исходного — `SELECT project FROM batch WHERE id = ?`). +- API: `project` из тела/запроса — `str` или отсутствует. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py +git commit -m "Хаб: пакет проекта — метка проекта, чужие метки и задачи, batch.project, повтор с project (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Доски в параметрах команд — `ensure_sprint.board_id`, `refresh.boards`, типы досок + +**Модель исполнителя:** sonnet (интеграция: постановка, выдача, отчёт агента и повтор). + +**Files:** +- Modify: `src/jira_hub/queue.py` (`_assign_boards` в `_check_setup`, `deliver_params`, `claim`, `report_result`, `retry_command`) +- Modify: `src/jira_hub/api.py` (`h_agent_command` — новая сигнатура `deliver_params`) +- Test: `tests/test_queue.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `projects.list_projects`, `projects.boards`, `projects.record_board_types` (Task 1); `mirror.labels_of` (Task 4); `_check_project` уже дописал метку проекта в `ensure_issue` (Task 5). +- Produces: + - `ensure_sprint` в базе — `{key, sprint, board_id}`; `board_id` от клиента отвергает `C.validate` («лишние параметры board_id»), SPEC команд не меняется. + - `queue.deliver_params(conn, cmd, files_dir) -> dict` (новый первый параметр): `ensure_attachment` — `size`, как раньше; `refresh` — `{**params, "boards": projects.boards(conn)}` всегда, в том числе `[]`; в базе `boards` нет. + - `report_result`: для `refresh`, если `result` — словарь с ключом `boards`, — `projects.record_board_types(conn, result["boards"])` в той же транзакции; результат без ключа `boards` (агент v11 или v12 без запроса типов) типы не трогает. + - `retry_command` снимает `board_id` с параметров `ensure_sprint` до проверок. + - Тексты 422 (`CommandError`, `i` — номер команды): «команда {i}: {key} не входит ни в один проект — доску для спринта не определить», «команда {i}: {key} в проектах {A, B} с разными досками», «команда {i}: у проекта {P} нет доски Jira», «команда {i}: у проекта {P} канбан-доска, спринтов нет», «команда {i}: тип доски проекта {P} ещё не известен — дождитесь обновления из Jira». + +- [ ] **Step 1: Write the failing tests** + +`tests/test_queue.py`: +```python +def sprint(key, name="Спринт 2"): + return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}} + +def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216} + +@pytest.mark.parametrize("setup, key, msg", [ + (dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"), + (dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"), + (dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"), + (dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"), + (dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале + (dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"), +]) +def test_sprint_board_rejected(conn, add_project, setup, key, msg): + add_project(conn) # AWPCA 4216 scrum + add_project(conn, "STOCKS_DRRK", **setup) + mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]}, + {"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], []) + with pytest.raises(CommandError, match=msg): + queue.create_batch(conn, "x", "claude", [sprint(key)]) + +def test_backlog_on_kanban_rejected(conn, add_project): ... # sprint "backlog", доска kanban → 422 «канбан-доска» +def test_sprint_two_projects_same_board(conn, add_project): ... # метки AWPCA и C, у обоих 4216 → board_id 4216 +def test_sprint_for_new_issue_uses_batch_labels(conn, add_project): + ... # пакет project="AWPCA": ensure_issue new:1 без labels + ensure_sprint new:1 → board_id 4216 (метка дописана Task 5) + +def test_client_board_id_rejected(conn, add_project): + add_project(conn) + with pytest.raises(CommandError, match="лишние параметры board_id"): + queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}]) + +def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint» + add_project(conn) + mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) + b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) + cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] + conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'") + r = queue.retry_command(conn, cid, "claude") + assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 + +def test_retry_premigration_sprint_without_board(conn, add_project): + ... # команда ensure_sprint вставлена SQL-ем с params без board_id в пакет с project NULL → retry ставит board_id 4216 + +def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 + add_project(conn); add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None); add_project(conn, "C", board_id=4216) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}]) + got = queue.claim(conn) + assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] + assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + +def test_refresh_boards_empty_list(conn, add_project): + add_project(conn, board_id=None, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert queue.claim(conn)["params"]["boards"] == [] + +@pytest.mark.parametrize("result, expected", [ + ({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int + ({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет + ({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется + ({"boards": {"abc": "scrum"}}, None), + (None, None), +]) +def test_refresh_result_board_types(conn, add_project, result, expected): + add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + queue.report_result(conn, c["id"], "done", result=result) + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected +``` +Существующие тесты с `ensure_sprint` (`test_temp_key_substitution_and_skip`, `test_reconcile_confirms_by_state`, `test_reconcile_does_not_overwrite_finished_command`, `test_bug_with_attachment_one_batch`) заводят `add_project(conn)` и кладут ключи задач (или `labels` у `ensure_issue`) с меткой `AWPCA`. + +`tests/test_api.py`: `GET /agent/commands/{id}` для взятого `refresh` отдаёт `boards`; полный круг «Тип доски определён» через HTTP — `POST /projects` STOCKS_DRRK с доской 5120, `claim` → в `params.boards` есть `{id: 5120, type: null}`, отчёт `done` с `result.boards = {"5120": "kanban"}` → `GET /projects` отдаёт `board_type = "kanban"`, затем `POST /batches` с `ensure_sprint` для задачи STOCKS_DRRK → 422 «канбан-доска». + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "sprint or board" -v` +Expected: FAIL (`board_id` не дописывается, `boards` нет в выдаче). + +- [ ] **Step 3: Implement** + +- `_assign_boards(conn, cmds)` вызывается из `_check_setup` (после проверок `bug`/вложений): метки временного ключа — `labels` его `ensure_issue` в пакете; иначе `mirror.labels_of`; проекты задачи — `[p for p in projects.list_projects(conn) if p["label"] in labels]`; множество их `board_id` (с `None`) из одного значения, иначе — «с разными досками»; дальше проверки по порядку: нет проектов → «не входит ни в один проект», доска `None` → «нет доски», `kanban` → «канбан-доска», `None`-тип → «ещё не известен»; иначе `c["params"] = {**p, "board_id": id}` (`P` в текстах — метка первого проекта). +- `deliver_params(conn, cmd, files_dir)`; вызовы в `claim` и `api.h_agent_command` передают `conn`. +- `report_result`: запись типов — в общей транзакции отчёта, только в ветке нетерминальной команды. +- `retry_command`: `params = {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")}`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py +git commit -m "Хаб: доска проекта в ensure_sprint, boards в выдаче refresh, типы досок из отчёта агента (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 7: MCP — `--project`, привязка инструментов, `projects`, `project_create` + +**Модель исполнителя:** sonnet (интеграция: декоратор привязки для обычных и async-инструментов, новые инструменты, правка тестов). + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` +- Modify: `src/jira_hub/cli.py` (`mcp --project`) +- Test: `tests/test_mcp.py`, `tests/test_cli.py` + +**Interfaces:** +- Consumes: HTTP `GET/POST /projects` (Task 3), `GET /issues?project=` (Task 4), `POST /batches {project}`, `GET /batches?project=`, `POST /commands/{id}/retry {project}` (Task 5). +- Produces (`mcp_server`): + - `PROJECT: str | None = None` — привязка сессии; `run(project: str | None = None) -> None` записывает её и запускает stdio. + - `NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"`; `unknown_project(label) -> str` = `f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"`. + - `scoped(required: bool)` — декоратор под `guard` (для обычных и async-функций): `PROJECT is None` и `required` → `NOT_BOUND` без запросов к хабу; `PROJECT` задан → `GET /projects`, метки нет → `unknown_project(PROJECT)`; иначе вызов инструмента. `required=True`: `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh`; `required=False`: `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`. Без декоратора: `projects`, `project_create`. + - `HUB_ROOT = Path(__file__).resolve().parents[2]`; `mcp_json(label: str) -> dict` = `{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp", "--project", label]}}}`. + - `fmt_projects(items, current: str | None) -> str` — строка заголовка `метка | название | доска | тип доски | задач`, строки с `—` для пустой доски и «неизвестен» для `board_type = None`, у текущего — суффикс ` (текущий)`; пустой список — «проектов нет». + - Инструменты `projects() -> str`, `project_create(label: str, name: str, board_id: int | None = None) -> str`. + +- [ ] **Step 1: Write the failing tests** + +Фикстура `live`: `add_project(conn)` (AWPCA) и `monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")`; `test_tools_roundtrip` работает в привязанной сессии. +```python +def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» + monkeypatch.setattr(mcp_server, "PROJECT", None) + calls = [] + real = HubClient.call + monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k)) + for out in (mcp_server.issues(), mcp_server.issue("A-1"), mcp_server.notes_set("A-1", work_kind="x"), + mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): + assert out == mcp_server.NOT_BOUND + assert calls == [] + assert "AWPCA" in mcp_server.projects() + assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки + +def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте» + monkeypatch.setattr(mcp_server, "PROJECT", "STOKS") + assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" + assert mcp_server.agent_status().startswith("проект STOKS не заведён") + assert "AWPCA" in mcp_server.projects() + +def test_bound_offline(monkeypatch): # Review Focus 4 + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert mcp_server.issues() == mcp_server.OFFLINE + +def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки» + live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]}, + {"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []}) + assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues() + out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) + b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}") + assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"] + assert mcp_server.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" + assert "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues + assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача + assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") + +def test_bound_batches_and_retry(live): ... # batches показывает только пакеты AWPCA; command_retry команды пакета без проекта → «ошибка хаба 422 … не из проекта AWPCA» + +def test_project_create(live, monkeypatch): # сценарий «Проект из чата» + monkeypatch.setattr(mcp_server, "PROJECT", None) + out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) + assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out + frag = json.loads(out[out.index("{"):]) + assert frag == mcp_server.mcp_json("STOCKS_DRRK") + assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] + assert live["client"].get("/batches")["batches"][0]["author"] == "claude" + assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409") + +def test_hub_root_is_repo(): + assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() + assert mcp_server.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(mcp_server.HUB_ROOT)] + +def test_fmt_projects(): + items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190}, + {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}] + out = mcp_server.fmt_projects(items, "AWPCA").splitlines() + assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" + assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" + assert mcp_server.fmt_projects([], None) == "проектов нет" +``` +(`live` дополнительно отдаёт `"client"` — клиентский `HubClient`.) `test_offline_message` ставит `PROJECT = "AWPCA"`. `test_batch_create_mentions_labels` — описание `batch_create` содержит «метку проекта сессии хаб дописывает в ensure_issue сам», «метки других проектов запрещены», «board_id для ensure_sprint выставляет хаб» и не содержит «AWPCA». `test_async_tools_are_coroutines_and_registered` проходит без изменений. `tests/test_cli.py`: `cli.main(["mcp", "--project", "AWPCA"])` вызывает `mcp_server.run("AWPCA")` (подменить `run`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_mcp.py tests/test_cli.py -v` +Expected: FAIL (`mcp_server` без `PROJECT`, `projects`, `project_create`). + +- [ ] **Step 3: Implement** по **Interfaces**: +- `issues` — `project=PROJECT` в `GET /issues`; `notes_set` — временный ключ `new:` (`commands.is_temp`) — без проверки; иначе сначала `GET /issues/{key}`, `PROJECT` не среди `labels` → `f"{key} не в проекте {PROJECT}"`; `batch_create` — `"project": PROJECT` в теле; `command_retry` — `{"author": AUTHOR, "project": PROJECT}`; `batches` — `project=PROJECT` (без привязки — все). +- `project_create` — `POST /projects {label, name, board_id, author: "claude"}` (`board_id=None` не отправлять); ответ: строка «проект <метка> «<название>» заведён, пакет обновления #», строка «Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: /mcp → reconnect, одобрите сервер проекта.» и `json.dumps(mcp_json(label), ensure_ascii=False, indent=2)` последним блоком. +- Описание `batch_create` — абзац про AWPCA заменить текстом из спеки mcp («метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб»). +- `cli`: `p = sub.add_parser("mcp", …); p.add_argument("--project")`; `mcp_server.run(args.project)`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest` — весь набор зелёный. + +- [ ] **Step 5: Commit** + +```bash +git add src/jira_hub/mcp_server.py src/jira_hub/cli.py tests/test_mcp.py tests/test_cli.py +git commit -m "MCP: привязка сессии к проекту (--project), инструменты projects и project_create (#61)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 8: Веб-приложение — текущий проект, выпадающий список, «Новый проект», доска и список по проекту + +**Модель исполнителя:** sonnet (интеграция: оболочка, роутинг, контекст проекта, страницы, e2e-адреса). + +**Files:** +- Create: `web/src/projects/current.ts`, `web/src/projects/current.test.ts` +- Create: `web/src/projects/ProjectContext.ts`, `web/src/projects/ProjectPicker.tsx`, `web/src/projects/ProjectGate.tsx`, `web/src/projects/NewProjectDialog.tsx`, `web/src/projects/projects.css`, `web/src/projects/projects.test.tsx` +- Modify: `web/src/api/types.ts` (`Project`, `CreatedProject`, `Batch.project`), `web/src/api/queries.ts` (`qk`, `useBoard`, `useProjects`, `useCreateProject`, инвалидация в `useSaveNote`) +- Modify: `web/src/shell/Layout.tsx`, `web/src/shell/pages.tsx` +- Modify: `web/src/board/BoardPage.tsx`, `web/src/board/useBoardSource.ts`, `web/src/issues/IssuesPage.tsx` +- Modify: `web/scripts/seed-hub.py` (метка `AWPCA` у задач фикстуры без `neighbor`) +- Test: `web/src/App.test.tsx`, `web/src/board/BoardPage.test.tsx`, `web/src/board/useBoardSource.test.tsx`, `web/src/issues/IssuesPage.test.tsx`, `web/src/api/queries.test.ts`; e2e — правка адресов в `web/e2e/board.spec.ts`, `web/e2e/issues.spec.ts`, `web/e2e/login.spec.ts` + +**Interfaces:** +- Consumes: `GET/POST /projects` (Task 3), `GET /board?project=`, `GET /issues?project=` (Task 4). +- Produces: + - `types.ts`: `interface Project { label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; issues_count: number }`; `interface CreatedProject { project: Project; batch_id: number }`; `Batch.project?: string | null`. + - `queries.ts`: `qk.projects = ["projects"]`, `qk.board(project: string) = ["board", project]`, `qk.boardAll = ["board"]` (инвалидация всех снимков); `useBoard(project: string)` → `GET /board?project=`; `useProjects()` → `GET /projects`, данные — `Project[]` (`r.projects ?? []`); `useCreateProject()` — мутация `POST /projects {label, name, board_id?, author: "viewer"}` → `CreatedProject`, при успехе инвалидирует `qk.projects`. + - `current.ts`: `REMEMBER_KEY = "hub-project"`; `type Resolution = { kind: "ok"; project: Project; fromUrl: boolean } | { kind: "unknown"; label: string } | { kind: "none" }`; `resolveProject(urlLabel: string | null, remembered: string | null, projects: Project[]): Resolution` (адрес → запомненный, если есть в списке → первый; метка из адреса, которой нет, — `unknown`; пустой список — `none`); `rememberedProject(): string | null`, `rememberProject(label: string): void` (обёрнуты в try/catch); `withProject(search: string, label: string): string` — query-строка с `project`, прочие параметры сохраняются. + - `ProjectContext.ts`: `type ProjectState = { kind: "loading" } | { kind: "error"; error: Error } | Resolution`; `interface ProjectCtx { state: ProjectState; projects: Project[]; select(label: string): void; openNew(): void }`; `ProjectContext`, `useProjectContext()`. + - `ProjectPicker()` — `` — строка `" new"` (с пробелом, который метка + содержать не может); выбор проекта из панели ведёт на раздел `/issues`, если открыт он, иначе (в том числе на + «не найдено» и под карточкой с чужим разделом) — на `/board`.** Обоснование: инвариант метки (без пробельных + символов) исключает коллизию; брифом раздел для остальных страниц не задан. Цена ошибки: константа `NEW` и + выражение `section` в `Layout.tsx`. +34. 2026-10-19 — **тесты, которые брифом не названы, поправлены под новые сигнатуры: `auth/LoginScreen.test.tsx` + (`useBoard("AWPCA")`, `qk.board("AWPCA")`), `issues/IssueModal.test.tsx` (мок отдаёт `/projects`); + в `App.test.tsx` ожидание «/app ведёт на доску» — через `waitFor`.** Обоснование: без `/projects` оболочка не + показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с + тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты. diff --git a/web/e2e/board.spec.ts b/web/e2e/board.spec.ts index ca5f2da..fea09f1 100644 --- a/web/e2e/board.spec.ts +++ b/web/e2e/board.spec.ts @@ -79,12 +79,12 @@ test("M-3: прокрутка и данные доски после окна к await page.locator(`.board .card[data-key="${key}"] a.open-card`).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.keyboard.press("Escape"); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); expect(boardLoads).toBe(0); // доска не перезагружалась // ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click"); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click"); await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2); }); diff --git a/web/e2e/issues.spec.ts b/web/e2e/issues.spec.ts index eca317d..4b1479b 100644 --- a/web/e2e/issues.spec.ts +++ b/web/e2e/issues.spec.ts @@ -16,7 +16,7 @@ test("фильтр → карточка окном → заметка сохра await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible(); await page.keyboard.press("Escape"); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page).toHaveURL(/\/app\/issues\?assignee=/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA&assignee=/); await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович"); }); @@ -46,14 +46,14 @@ test("переход по связи и «назад»; ✕ и щелчок по await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible(); await d.locator(".links-sec").getByRole("link", { name: "AURORA-9002", exact: true }).click(); await d.getByRole("button", { name: "Закрыть" }).click(); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); await page.getByRole("link", { name: "AURORA-9003", exact: true }).click(); await expect(page.getByRole("dialog")).toBeVisible(); await page.mouse.click(5, 300); // фон слева от окна await expect(page.getByRole("dialog")).toHaveCount(0); await page.goBack(); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); @@ -65,7 +65,7 @@ test("прямой адрес и перезагрузка: окно поверх await page.reload(); await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible(); await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click(); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); await expect(page.getByRole("dialog")).toHaveCount(0); }); @@ -156,7 +156,7 @@ test("несохранённая заметка: Esc спрашивает, «О await page.keyboard.press("Escape"); await d.getByRole("button", { name: "Уйти" }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); - await expect(page).toHaveURL(/\/app\/issues$/); + await expect(page).toHaveURL(/\/app\/issues\?project=AWPCA$/); }); test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => { diff --git a/web/e2e/login.spec.ts b/web/e2e/login.spec.ts index 5bc9040..d124265 100644 --- a/web/e2e/login.spec.ts +++ b/web/e2e/login.spec.ts @@ -13,5 +13,5 @@ test("неверный токен — пояснение; верный — до test("/ и /app ведут на доску", async ({ page }) => { await login(page); await page.goto("/app"); - await expect(page).toHaveURL(/\/app\/board$/); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); }); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 767d93f..0d66620 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -13,8 +13,15 @@ extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exis extra_issues = extras.get("issues", {}) conn = db.connect(paths.db_path()) settings.ensure_defaults(conn) + +def own_labels(i): + # свои задачи (не соседи) принадлежат проекту AWPCA: метка нужна для разреза GET /board?project=AWPCA + labels = list(i.get("labels") or []) + return labels if i["neighbor"] or "AWPCA" in labels else ["AWPCA", *labels] + + issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} - | extra_issues.get(i["key"], {}) for i in snap["issues"]] + | {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) for i in snap["issues"]: diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 1cce5c8..070faf4 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createMemoryRouter } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; @@ -10,12 +10,14 @@ import { json, mockFetch } from "./test/fetchMock.ts"; const at = (path: string) => createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 0 }; describe("оболочка", () => { - beforeEach(() => { setToken("tok"); mockFetch(() => json(200, {})); }); + beforeEach(() => { setToken("tok"); mockFetch((_m, url) => json(200, url.pathname === "/projects" ? { projects: [A] } : {})); }); it("/app ведёт на доску", async () => { render(); - expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + // страница сначала показывает заглушку «Загрузка настроек», затем её заменяет другой элемент с тем же testid + await waitFor(() => expect(screen.getByTestId("page-board")).toBeInTheDocument()); }); it("переход «Задачи» и обратно", async () => { @@ -23,9 +25,11 @@ describe("оболочка", () => { render(); await userEvent.click(screen.getByRole("link", { name: "Задачи" })); expect(router.state.location.pathname).toBe("/app/issues"); + expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-issues")).toBeInTheDocument(); await userEvent.click(screen.getByRole("link", { name: "Доска" })); expect(router.state.location.pathname).toBe("/app/board"); + expect(router.state.location.search).toBe("?project=AWPCA"); expect(await screen.findByTestId("page-board")).toBeInTheDocument(); }); diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts index ed8eabc..1d194ac 100644 --- a/web/src/api/queries.test.ts +++ b/web/src/api/queries.test.ts @@ -4,7 +4,7 @@ import { createElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it } from "vitest"; import { setToken } from "../auth/token.ts"; import { json, mockFetch } from "../test/fetchMock.ts"; -import { qk, useIssues, useSaveNote } from "./queries.ts"; +import { qk, useBoard, useIssues, useSaveNote } from "./queries.ts"; import type { IssueDetail } from "./types.ts"; const wrap = (client: QueryClient) => ({ children }: { children: ReactNode }) => @@ -34,4 +34,16 @@ describe("запросы", () => { await result.current.mutateAsync({} as never); expect(client.getQueryData(qk.issue("A-1"))?.note).toEqual(note); }); + + it("useBoard(project): GET /board?project=; ключ снимка — по проекту, qk.boardAll — общий префикс", async () => { + const seen: (string | null)[] = []; + mockFetch((_m, url) => { seen.push(url.searchParams.get("project")); return json(200, { issues: [], links: [] }); }); + const client = new QueryClient(); + const { result } = renderHook(() => useBoard("AWPCA"), { wrapper: wrap(client) }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(seen).toEqual(["AWPCA"]); + expect(qk.board("AWPCA")).toEqual(["board", "AWPCA"]); + expect(client.getQueryState(qk.board("AWPCA"))).toBeDefined(); + expect(client.getQueryCache().findAll({ queryKey: qk.boardAll })).toHaveLength(1); + }); }); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index c38aa2f..d44bb2b 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,15 +1,17 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client.ts"; -import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; +import type { BoardSnapshot, CreatedProject, HubSettings, IssueDetail, IssueRow, Note, NoteFields, Project } from "./types.ts"; // фильтры списка в адресе; hide_closed в адрес не пишется — его выводит список из флажка «скрыть закрытые» (closed=1 — снят) export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q"] as const; -export type IssueFilters = Partial>; +export type IssueFilters = Partial>; export const ISSUE_PAGE = 50; export const qk = { settings: ["settings"] as const, - board: ["board"] as const, + projects: ["projects"] as const, + board: (project: string) => ["board", project] as const, + boardAll: ["board"] as const, // префикс: все снимки досок сразу (инвалидация) issues: (f: IssueFilters) => ["issues", f] as const, issue: (key: string) => ["issue", key] as const, }; @@ -17,7 +19,21 @@ export const qk = { export const useSettings = () => useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); -export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get("/board") }); +export const useBoard = (project: string) => + useQuery({ queryKey: qk.board(project), queryFn: () => api.get("/board", { project }) }); + +export const useProjects = () => + useQuery({ queryKey: qk.projects, queryFn: () => api.get<{ projects?: Project[] }>("/projects").then((r) => r.projects ?? []) }); + +// «Новый проект»: пакет обновления хаб ставит сам (batch_id), автор — viewer, чтобы доска подхватила его ход +export function useCreateProject() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (f: { label: string; name: string; board_id?: number }) => + api.post("/projects", { ...f, author: "viewer" }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.projects }), // ждём список: onCreated уже видит новый проект + }); +} export const useIssue = (key: string) => useQuery({ queryKey: qk.issue(key), queryFn: () => api.get(`/issues/${encodeURIComponent(key)}`) }); @@ -41,7 +57,7 @@ export function useSaveNote(key: string) { onSuccess: (note) => { qc.setQueryData(qk.issue(key), (d) => (d ? { ...d, note } : d)); void qc.invalidateQueries({ queryKey: ["issues"] }); - void qc.invalidateQueries({ queryKey: qk.board }); + void qc.invalidateQueries({ queryKey: qk.boardAll }); }, }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 746574c..5a7fda7 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -56,7 +56,14 @@ export interface BatchCommand { } export interface Batch { id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus; - counts: Record; remaining_seconds: number; commands?: BatchCommand[]; + counts: Record; remaining_seconds: number; commands?: BatchCommand[]; project?: string | null; } export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } export interface CreatedBatch { id: number; estimate_seconds: number; count: number } + +// проект-метка (GET/POST /projects): board_type хаб определяет по отчёту агента, до него — null +export interface Project { + label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; + issues_count: number; +} +export interface CreatedProject { project: Project; batch_id: number } diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index a4ffa71..edec09a 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -9,7 +9,7 @@ import { clearToken, getAuth, setToken } from "./token.ts"; const app =

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

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

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

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

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

; } describe("вход", () => { beforeEach(() => clearToken()); @@ -76,7 +76,7 @@ describe("вход", () => { const { client } = renderWithProviders(); act(() => setToken("new-token")); release(json(401, { error: "unauthorized" })); - await waitFor(() => expect(client.getQueryState(qk.board)?.status).toBe("error")); // 401 точно обработан + await waitFor(() => expect(client.getQueryState(qk.board("AWPCA"))?.status).toBe("error")); // 401 точно обработан expect(screen.getByText("страница приложения")).toBeInTheDocument(); expect(getAuth()).toEqual({ token: "new-token", reason: "none" }); }); diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index 9ef0da5..f735dca 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -26,7 +26,7 @@ function hub(over: Record Response> = {}) { return json(404, { error: "not_found" }); }); } -const page = () => renderWithProviders(, { route: "/board", path: "/board" }); +const page = () => renderWithProviders(, { route: "/board", path: "/board" }); describe("страница доски", () => { beforeEach(() => setToken("tok")); @@ -39,6 +39,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 +152,7 @@ describe("страница доски", () => { it("T12.2: переключатель пишет флаг в хранилище один раз, в том числе в StrictMode", async () => { hub(); - renderWithProviders(, { route: "/board", path: "/board" }); + renderWithProviders(, { route: "/board", path: "/board" }); await screen.findByText(/^Хаб · данные на/); const set = vi.spyOn(Storage.prototype, "setItem"); fireEvent.click(screen.getByRole("button", { name: /критический путь/ })); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index ebeddb9..cda7acc 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -16,11 +16,11 @@ 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 src = useBoardSource(d && !d.missing.length ? d.dicts : null, project); const prefs = useBoardPrefs(src.model); const today = todayIso(); const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today), diff --git a/web/src/board/useBoardSource.test.tsx b/web/src/board/useBoardSource.test.tsx index 3d102d8..73ec91e 100644 --- a/web/src/board/useBoardSource.test.tsx +++ b/web/src/board/useBoardSource.test.tsx @@ -16,7 +16,7 @@ const fx = (name: string) => readFileSync(join(import.meta.dirname, "__fixtures_ const SNAP = JSON.parse(fx("hub-basic.board.json")); const CSV = fx("jira-export.csv"); const { dicts } = dictsFromSettings(settings); -const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, { pollMs: 10 }), +const hook = (client = makeQueryClient()) => ({ client, ...renderHook(() => useBoardSource(dicts, "AWPCA", { pollMs: 10 }), { wrapper: ({ children }: { children: ReactNode }) => {children} }) }); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const deferred = () => { let resolve!: (v: T) => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; @@ -65,7 +65,7 @@ describe("useBoardSource", () => { act(() => { result.current.loadFile("jira-export.csv", CSV); }); expect(result.current.source).toBe("file"); slow.resolve(json(200, SNAP)); - await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // ответ хаба точно применён к кэшу + await waitFor(() => expect(client.getQueryData(qk.board("AWPCA"))).toBeDefined()); // ответ хаба точно применён к кэшу expect(result.current.source).toBe("file"); expect(result.current.label).toBe("jira-export.csv (из файла)"); act(() => result.current.reload()); diff --git a/web/src/board/useBoardSource.ts b/web/src/board/useBoardSource.ts index c0654cf..2ca77d6 100644 --- a/web/src/board/useBoardSource.ts +++ b/web/src/board/useBoardSource.ts @@ -30,9 +30,9 @@ export interface BoardSource { const terminal = (b: Batch | undefined) => !!b && (b.status === "done" || b.status === "cancelled"); -export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource { +export function useBoardSource(dicts: Dicts | null, project: string, { pollMs = POLL_MS }: { pollMs?: number } = {}): BoardSource { const qc = useQueryClient(); - const board = useBoard(); + const board = useBoard(project); const [source, setSource] = useState({ kind: "hub" }); const [event, setEvent] = useState(null); const [active, setActive] = useState(null); @@ -111,8 +111,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll setActive(null); qc.removeQueries({ queryKey: ["batch", id] }); skipPickup.current = true; - void qc.invalidateQueries({ queryKey: qk.board }); // снимок обновится; показанный файл не заменяется (M-1) - }, [qc]); + void qc.invalidateQueries({ queryKey: qk.board(project) }); // снимок обновится; показанный файл не заменяется (M-1) + }, [qc, project]); useEffect(() => { if (!active) return; @@ -165,8 +165,8 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll const reload = useCallback(() => { setSource({ kind: "hub" }); setEvent((cur) => (cur?.kind === "batch" ? cur : null)); - void qc.refetchQueries({ queryKey: qk.board }); - }, [qc]); + void qc.refetchQueries({ queryKey: qk.board(project) }); + }, [qc, project]); const loadFile = useCallback((fileName: string, text: string): JsonImport | null => { if (!dicts) return null; @@ -181,7 +181,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll if (board.data) { // режим хаба: из файла — раскладка и фильтры, данные — из хаба setSource({ kind: "hub" }); setEvent({ kind: "json-applied", fileName }); - void qc.refetchQueries({ queryKey: qk.board }); + void qc.refetchQueries({ queryKey: qk.board(project) }); } else { // хаб не ответил — снимок из файла setSource({ kind: "file", model: r.model }); setEvent({ kind: "hub-down", hasData: r.model.nodes.length > 0, fromFile: true }); @@ -191,7 +191,7 @@ export function useBoardSource(dicts: Dicts | null, { pollMs = POLL_MS }: { poll setEvent({ kind: "file-error", fileName, reason: e instanceof Error ? e.message : String(e) }); return null; } - }, [dicts, board.data, qc]); + }, [dicts, board.data, qc, project]); const ls = board.data?.last_sync ?? null; // заглушка без данных — один и тот же объект между рендерами (от identity модели зависит выбор спринтов на странице) diff --git a/web/src/issues/IssueModal.test.tsx b/web/src/issues/IssueModal.test.tsx index 4a07f01..7ff09eb 100644 --- a/web/src/issues/IssueModal.test.tsx +++ b/web/src/issues/IssueModal.test.tsx @@ -15,6 +15,7 @@ const rows = board.issues.map((i) => ({ ...i, id: i.key, description: null, repo function hub() { return mockFetch((_m, url) => { const p = url.pathname; + if (p === "/projects") return json(200, { projects: [{ label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "", issues_count: 9 }] }); if (p === "/settings") return json(200, settings); if (p === "/board") return json(200, board); if (p === "/issues") return json(200, { issues: rows }); diff --git a/web/src/issues/IssuesPage.test.tsx b/web/src/issues/IssuesPage.test.tsx index 32c03dd..76d885a 100644 --- a/web/src/issues/IssuesPage.test.tsx +++ b/web/src/issues/IssuesPage.test.tsx @@ -30,9 +30,10 @@ describe("список задач", () => { it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2&neighbor=1", path: "/issues" }); expect(await screen.findByRole("link", { name: "AURORA-9001" })).toHaveAttribute("href", "/issues/AURORA-9001"); const q = issuesCalls(fetch)[0].searchParams; + expect(q.get("project")).toBe("AWPCA"); expect(q.get("sprint")).toBe("AWPCA_Спринт 2"); expect(q.get("neighbor")).toBe("1"); expect(q.get("limit")).toBe("50"); @@ -41,7 +42,7 @@ describe("список задач", () => { it("выбор исполнителя пишет его в адрес и в запрос", async () => { const fetch = hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); await userEvent.selectOptions(screen.getByLabelText("Исполнитель"), "Петров Пётр Петрович"); await waitFor(() => expect(router.state.location.search).toContain("assignee=")); @@ -50,7 +51,7 @@ describe("список задач", () => { it("«Показать ещё» до неполной порции", async () => { const fetch = hub((p) => (p.get("offset") === "0" ? Array.from({ length: 50 }, (_, i) => row(i + 1)) : Array.from({ length: 17 }, (_, i) => row(i + 51)))); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); await userEvent.click(await screen.findByRole("button", { name: "Показать ещё" })); await waitFor(() => expect(screen.getAllByRole("row")).toHaveLength(1 + 67)); expect(screen.queryByRole("button", { name: "Показать ещё" })).toBeNull(); @@ -59,7 +60,7 @@ describe("список задач", () => { it("поиск — один запрос после паузы ввода", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const before = issuesCalls(fetch).length; await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); @@ -75,7 +76,7 @@ describe("список задач", () => { if (url.pathname === "/board") return json(200, SNAP); return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("alert")).toHaveTextContent("хаб не отвечает"); down = false; await userEvent.click(screen.getByRole("button", { name: "Повторить" })); @@ -84,13 +85,13 @@ describe("список задач", () => { it("эпик показывается названием из снимка", async () => { hub(() => [row(1)]); - renderWithProviders(, { route: "/issues", path: "/issues" }); + renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByRole("cell", { name: "Тестовый эпик" })).toBeInTheDocument(); }); it("T13.2: внешняя смена адреса обновляет поле поиска и не перезаписывается через 300 мс", async () => { hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues?q=свод", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues?q=свод", path: "/issues" }); const box = await screen.findByLabelText("Поиск по ключу или теме"); expect(box).toHaveValue("свод"); await act(async () => { await router.navigate("/issues"); }); @@ -102,7 +103,7 @@ describe("список задач", () => { it("T13.2: ввод поиска пишется в адрес через replace (шаг «назад» не застревает)", async () => { hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const before = router.state.historyAction; await userEvent.type(screen.getByLabelText("Поиск по ключу или теме"), "свод"); @@ -113,7 +114,7 @@ describe("список задач", () => { it("T13.1: значение фильтра из адреса вне вариантов — в списке с пометкой", async () => { hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 0", path: "/issues" }); const sel = await screen.findByLabelText("Спринт"); await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 0 (нет на доске)" })).toBeInTheDocument()); expect(sel).toHaveValue("AWPCA_Спринт 0"); @@ -126,13 +127,13 @@ describe("список задач", () => { if (url.pathname === "/board") return board.promise; return json(200, { issues: [row(1)] }); }); - const { client } = renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + const { client } = renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const sel = await screen.findByLabelText("Спринт"); expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument(); - expect(client.getQueryData(qk.board)).toBeUndefined(); + expect(client.getQueryData(qk.board("AWPCA"))).toBeUndefined(); board.resolve(json(200, SNAP)); - await waitFor(() => expect(client.getQueryData(qk.board)).toBeDefined()); // снимок реально применён + await waitFor(() => expect(client.getQueryData(qk.board("AWPCA"))).toBeDefined()); // снимок реально применён await waitFor(() => expect(within(sel).getByRole("option", { name: "AWPCA_Спринт 2" })).toBeInTheDocument()); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); expect(sel).toHaveValue("AWPCA_Спринт 2"); @@ -144,7 +145,7 @@ describe("список задач", () => { if (url.pathname === "/board") return new Promise(() => {}); // снимок не приходит до конца теста return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); + renderWithProviders(, { route: "/issues?sprint=AWPCA_Спринт 2", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const sel = await screen.findByLabelText("Спринт"); expect(within(sel).queryByRole("option", { name: /нет на доске/ })).toBeNull(); @@ -155,7 +156,7 @@ describe("список задач", () => { it("закрытые скрыты по умолчанию; снятый флажок — closed=1 и запрос без hide_closed", async () => { const fetch = hub(() => [row(1)]); - const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); + const { router } = renderWithProviders(, { route: "/issues", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(hideBox()).toBeChecked(); let q = issuesCalls(fetch).at(-1)!.searchParams; @@ -173,7 +174,7 @@ describe("список задач", () => { it("адрес closed=1 — флажок снят, запрос без hide_closed", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); + renderWithProviders(, { route: "/issues?closed=1", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(hideBox()).not.toBeChecked(); expect(issuesCalls(fetch).every((u) => !u.searchParams.has("hide_closed"))).toBe(true); @@ -181,7 +182,7 @@ describe("список задач", () => { it("выбран закрытый статус Jira — hide_closed не отправляется", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=Готово", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); const q = issuesCalls(fetch).at(-1)!.searchParams; expect(q.get("status")).toBe("Готово"); @@ -191,7 +192,7 @@ describe("список задач", () => { it("выбран незакрытый статус Jira — hide_closed отправляется", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=В работе", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(issuesCalls(fetch).at(-1)!.searchParams.get("hide_closed")).toBe("1"); }); @@ -203,7 +204,7 @@ describe("список задач", () => { if (url.pathname === "/board") return json(200, SNAP); return json(200, { issues: [row(1)] }); }); - renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); + renderWithProviders(, { route: "/issues?status=Снято", path: "/issues" }); st.resolve(json(200, settings)); await screen.findByRole("link", { name: "AURORA-9001" }); const calls = () => fetch.mock.calls.map(([u]) => new URL(String(u), "http://hub.test")).filter((u) => u.pathname === "/issues"); @@ -212,7 +213,7 @@ describe("список задач", () => { it("статуса по факту нет: ни фильтра, ни колонки; fact_status из адреса не уходит в запрос", async () => { const fetch = hub(() => [row(1)]); - renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); + renderWithProviders(, { route: "/issues?fact_status=В работе", path: "/issues" }); await screen.findByRole("link", { name: "AURORA-9001" }); expect(screen.queryByLabelText("Статус по факту")).toBeNull(); expect(screen.queryByRole("columnheader", { name: "Статус по факту" })).toBeNull(); diff --git a/web/src/issues/IssuesPage.tsx b/web/src/issues/IssuesPage.tsx index 7157572..4419b92 100644 --- a/web/src/issues/IssuesPage.tsx +++ b/web/src/issues/IssuesPage.tsx @@ -12,7 +12,7 @@ const SELECTS = [ ] as const; const Q_DELAY = 300; // поиск — после паузы ввода (R-7) -export function IssuesPage() { +export function IssuesPage({ project }: { project: string }) { const [params, setParams] = useSearchParams(); const filters = useMemo(() => Object.fromEntries(ISSUE_FILTER_NAMES.map((n) => [n, params.get(n) ?? ""]).filter(([, v]) => v)), [params]); @@ -35,14 +35,14 @@ export function IssuesPage() { return () => clearTimeout(t); }, [q, filters.q, setParams]); - const board = useBoard(), settings = useSettings(); + const board = useBoard(project), settings = useSettings(); const opts = useMemo(() => filterOptions(board.data, settings.data), [board.data, settings.data]); const names = useMemo(() => epicNames(board.data), [board.data]); // закрытые скрыты, пока флажок не снят (closed=1); выбранный закрытый статус Jira скрытие отменяет const showClosed = params.get("closed") === "1"; const closed = useClosedStatuses(); - const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? filters : { ...filters, hide_closed: "1" }), - [filters, showClosed, closed]); + const query = useMemo(() => (showClosed || isClosed(filters.status, closed) ? { ...filters, project } : { ...filters, project, hide_closed: "1" }), + [filters, project, showClosed, closed]); const list = useIssues(query); const rows = list.data?.pages.flat() ?? []; diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx new file mode 100644 index 0000000..576aaf8 --- /dev/null +++ b/web/src/projects/NewProjectDialog.tsx @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; +import { ApiError } from "../api/client.ts"; +import { useCreateProject } from "../api/queries.ts"; +import type { Project } from "../api/types.ts"; +import "./projects.css"; + +// окно «Новый проект» (как IssueModal): держит фокус; Esc и «Отмена» — onClose, ошибка хаба — рядом с «Создать» +export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void }) { + const ref = useRef(null); + const [label, setLabel] = useState(""), [name, setName] = useState(""), [board, setBoard] = useState(""); + const create = useCreateProject(); + useEffect(() => { const d = ref.current; if (d && !d.open) d.showModal(); }, []); + + const submit = (e: FormEvent) => { + e.preventDefault(); + const f: { label: string; name: string; board_id?: number } = { label: label.trim(), name: name.trim() }; + if (board.trim() !== "") f.board_id = Number(board); + create.mutate(f, { onSuccess: (r) => onCreated(r.project) }); + }; + const err = create.error; + const text = err ? (err instanceof ApiError ? err.detail ?? err.message : err.message) : null; + + return ( + { e.preventDefault(); onClose(); }}> +
+

Новый проект

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

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

; + case "error": return

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

; + case "unknown": + return

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

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

; + case "none": + return

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

+
; + case "ok": return <>{children(state.project)}; + } +} diff --git a/web/src/projects/ProjectPicker.tsx b/web/src/projects/ProjectPicker.tsx new file mode 100644 index 0000000..3f8e1b3 --- /dev/null +++ b/web/src/projects/ProjectPicker.tsx @@ -0,0 +1,16 @@ +import { useProjectContext } from "./ProjectContext.ts"; + +const NEW = " new"; // служебное значение пункта «Новый проект…»: метка пробелов содержать не может + +export function ProjectPicker() { + const { state, projects, select, openNew } = useProjectContext(); + const current = state.kind === "ok" ? state.project.label : ""; + return ( + + ); +} diff --git a/web/src/projects/current.test.ts b/web/src/projects/current.test.ts new file mode 100644 index 0000000..f7b0fe6 --- /dev/null +++ b/web/src/projects/current.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Project } from "../api/types.ts"; +import { rememberedProject, rememberProject, REMEMBER_KEY, resolveProject, withProject } from "./current.ts"; + +const A = { label: "AWPCA", name: "АРМ КА" } as Project, S = { label: "STOCKS_DRRK", name: "Запасы ДРРК" } as Project; + +describe("текущий проект", () => { + afterEach(() => vi.restoreAllMocks()); + + it("адрес, затем запомненный, затем первый", () => { + expect(resolveProject("STOCKS_DRRK", "AWPCA", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: true }); + expect(resolveProject(null, "STOCKS_DRRK", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: false }); + expect(resolveProject(null, "GONE", [A, S])).toEqual({ kind: "ok", project: A, fromUrl: false }); + expect(resolveProject("NOPE", "AWPCA", [A, S])).toEqual({ kind: "unknown", label: "NOPE" }); + expect(resolveProject(null, null, [])).toEqual({ kind: "none" }); + }); + + it("withProject сохраняет фильтры", () => { + expect(withProject("?sprint=С2&project=AWPCA", "STOCKS_DRRK")).toBe("?sprint=%D0%A12&project=STOCKS_DRRK"); + expect(withProject("", "AWPCA")).toBe("?project=AWPCA"); + }); + + it("запоминание проекта в localStorage", () => { + rememberProject("STOCKS_DRRK"); + expect(localStorage.getItem(REMEMBER_KEY)).toBe("STOCKS_DRRK"); + expect(rememberedProject()).toBe("STOCKS_DRRK"); + }); + + it("память выбора переживает недоступное хранилище", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("закрыто"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("закрыто"); }); + expect(rememberedProject()).toBeNull(); + expect(() => rememberProject("AWPCA")).not.toThrow(); + }); +}); diff --git a/web/src/projects/current.ts b/web/src/projects/current.ts new file mode 100644 index 0000000..d5f4390 --- /dev/null +++ b/web/src/projects/current.ts @@ -0,0 +1,35 @@ +import type { Project } from "../api/types.ts"; + +// выбор проекта в этом браузере; ключ один на все проекты (спека web-app «Проект в приложении») +export const REMEMBER_KEY = "hub-project"; + +export type Resolution = + | { kind: "ok"; project: Project; fromUrl: boolean } + | { kind: "unknown"; label: string } + | { kind: "none" }; + +// адрес → запомненный (если есть в списке) → первый; метка из адреса, которой нет в списке, — unknown, а не подмена +export function resolveProject(urlLabel: string | null, remembered: string | null, projects: Project[]): Resolution { + if (urlLabel) { + const p = projects.find((x) => x.label === urlLabel); + return p ? { kind: "ok", project: p, fromUrl: true } : { kind: "unknown", label: urlLabel }; + } + if (!projects.length) return { kind: "none" }; + const p = (remembered && projects.find((x) => x.label === remembered)) || projects[0]; + return { kind: "ok", project: p, fromUrl: false }; +} + +export function rememberedProject(): string | null { + try { return localStorage.getItem(REMEMBER_KEY); } catch { return null; } +} + +export function rememberProject(label: string): void { + try { localStorage.setItem(REMEMBER_KEY, label); } catch { /* хранилище недоступно: выбор живёт только в адресе */ } +} + +// query-строка с project; остальные параметры сохраняются +export function withProject(search: string, label: string): string { + const p = new URLSearchParams(search); + p.set("project", label); + return "?" + p.toString(); +} diff --git a/web/src/projects/projects.css b/web/src/projects/projects.css new file mode 100644 index 0000000..54c61a5 --- /dev/null +++ b/web/src/projects/projects.css @@ -0,0 +1,10 @@ +/* выбор проекта и окно «Новый проект» */ +.appbar .project-picker{font:inherit;font-size:14px;height:30px;max-width:280px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} +.project-gate{display:flex;flex-direction:column;gap:12px;align-items:flex-start;margin:24px 0} +.project-dialog{border:1px solid var(--line);border-radius:10px;background:var(--panel);color:var(--fg);padding:16px 20px;min-width:340px} +.project-dialog::backdrop{background:rgba(0,0,0,.4)} +.project-dialog form{display:flex;flex-direction:column;gap:10px} +.project-dialog h2{margin:0 0 4px;font-size:18px} +.project-dialog label{display:flex;flex-direction:column;gap:2px;font-size:13px;color:var(--muted)} +.project-dialog input{font:inherit;font-size:14px;height:32px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--fg);padding:0 8px} +.project-dialog .actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px} diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx new file mode 100644 index 0000000..d7a0a06 --- /dev/null +++ b/web/src/projects/projects.test.tsx @@ -0,0 +1,147 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createMemoryRouter } from "react-router"; +import { beforeEach, describe, expect, it } from "vitest"; +import { App } from "../App.tsx"; +import { setToken } from "../auth/token.ts"; +import settings from "../board/__fixtures__/settings.json"; +import { routes } from "../routes.tsx"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { issue } from "../test/issueFixtures.ts"; +import { REMEMBER_KEY } from "./current.ts"; + +const SNAP = JSON.parse(readFileSync(join(import.meta.dirname, "..", "board", "__fixtures__", "hub-basic.board.json"), "utf8")); +const A = { label: "AWPCA", name: "АРМ КА", board_id: null, board_type: null, created_at: "2026-10-01T00:00:00+00:00", issues_count: 9 }; +const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, board_type: null, created_at: "2026-10-02T00:00:00+00:00", issues_count: 0 }; +const ROW = { ...SNAP.issues[1], fetched_at: "" }; + +interface Call { method: string; url: URL; body: unknown } +function hub(projects: unknown[] = [A, S], over: Record Response> = {}) { + const calls: Call[] = []; + const fetch = mockFetch((method, url, body) => { + calls.push({ method, url, body }); + const k = `${method} ${url.pathname}`; + if (over[k]) return over[k](body); + if (k === "GET /projects") return json(200, { projects }); + if (k === "GET /settings") return json(200, settings); + if (k === "GET /board") return json(200, SNAP); + if (k === "GET /issues") return json(200, { issues: [ROW] }); + if (k === "GET /issues/AURORA-9001") return json(200, issue("AURORA-9001")); + if (k === "GET /batches") return json(200, { batches: [] }); + if (k === "GET /agent") return json(200, { online: true }); + return json(404, { error: "not_found" }); + }); + const get = (path: string) => calls.filter((c) => c.method === "GET" && c.url.pathname === path); + return { fetch, calls, get }; +} +const open = (path: string) => { + const router = createMemoryRouter(routes, { initialEntries: [path], basename: "/app" }); + render(); + return router; +}; + +describe("проект в приложении", () => { + beforeEach(() => { setToken("tok"); localStorage.clear(); }); + + it("первый вход после выкатки: проект подставляется в адрес и запоминается", async () => { + const h = hub(); + const router = open("/app/board"); + await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); + 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"); + 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")); + }); + + 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 () => { + const h = hub(); + const router = open("/app/issues/AURORA-9001"); + expect(await screen.findByTestId("issue-card")).toBeInTheDocument(); + await waitFor(() => expect(h.get("/issues").some((c) => c.url.searchParams.get("project") === "AWPCA")).toBe(true)); + expect(router.state.location.pathname).toBe("/app/issues/AURORA-9001"); + expect(router.state.location.search).toBe(""); + expect(screen.getByTestId("issue-card")).toBeInTheDocument(); + }); +}); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index f096bf8..3fe5e4f 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -1,10 +1,15 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, matchPath, useLocation, useNavigate, useRoutes } from "react-router"; +import { useProjects } from "../api/queries.ts"; import { clearToken } from "../auth/token.ts"; import { CloseAbortContext } from "../issues/closeAbort.ts"; import { IssueModal } from "../issues/IssueModal.tsx"; import { cardState, LIST_BACKGROUND } from "../issues/modal.ts"; +import { rememberedProject, rememberProject, resolveProject, withProject } from "../projects/current.ts"; +import { NewProjectDialog } from "../projects/NewProjectDialog.tsx"; +import { ProjectContext, type ProjectCtx, type ProjectState } from "../projects/ProjectContext.ts"; +import { ProjectPicker } from "../projects/ProjectPicker.tsx"; import { ErrorBoundary } from "./ErrorBoundary.tsx"; import { PAGE_ROUTES } from "./pages.tsx"; import "./shell.css"; @@ -48,14 +53,40 @@ export function Layout() { const abortClose = useCallback(() => { closingDirect.current = false; }, []); const at = pageLocation.pathname; + + // текущий проект: адрес страницы (под окном карточки — адрес списка) → запомненный → первый; карточка от него не зависит + const projects = useProjects(); + const urlLabel = new URLSearchParams(pageLocation.search).get("project"); + const state = useMemo(() => projects.isPending ? { kind: "loading" } + : projects.error ? { kind: "error", error: projects.error } + : resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]); + const okLabel = state.kind === "ok" ? state.project.label : null; + const fromUrl = state.kind === "ok" && state.fromUrl; + const onPage = at === "/board" || at === "/issues"; + useEffect(() => { if (okLabel) rememberProject(okLabel); }, [okLabel]); + // выбранный проект записывается в адрес без новой записи истории; с открытой карточкой адрес не трогаем (design 10) + useEffect(() => { + if (okLabel && !fromUrl && issueKey === null && onPage) { + void navigate(at + withProject(pageLocation.search, okLabel), { replace: true }); + } + }, [okLabel, fromUrl, issueKey, onPage, at, pageLocation.search, navigate]); + + const [newOpen, setNewOpen] = useState(false); + const section = at === "/issues" ? "/issues" : "/board"; + const select = useCallback((label: string) => { void navigate({ pathname: section, search: withProject("", label) }); }, [navigate, section]); + const ctx = useMemo(() => ({ state, projects: projects.data ?? [], select, openNew: () => setNewOpen(true) }), + [state, projects.data, select]); + const tab = (to: string, label: string) => { const active = at === to || at.startsWith(to + "/"); - return {label}; + return {label}; }; return ( - <> +
Хаб Jira + @@ -64,6 +95,7 @@ export function Layout() { {page} {issueKey && } - + {newOpen && setNewOpen(false)} onCreated={(p) => { setNewOpen(false); select(p.label); }} />} + ); } diff --git a/web/src/shell/pages.tsx b/web/src/shell/pages.tsx index 5111b01..fda044e 100644 --- a/web/src/shell/pages.tsx +++ b/web/src/shell/pages.tsx @@ -1,12 +1,13 @@ import { Navigate, type RouteObject } from "react-router"; import { BoardPage } from "../board/BoardPage.tsx"; import { IssuesPage } from "../issues/IssuesPage.tsx"; +import { ProjectGate } from "../projects/ProjectGate.tsx"; import { NotFound } from "./NotFound.tsx"; // страницы приложения; Layout рисует их по адресу страницы под окном карточки (design 10) export const PAGE_ROUTES: RouteObject[] = [ { path: "/", element: }, - { path: "/board", element: }, - { path: "/issues", element: }, + { path: "/board", element: {(p) => } }, + { path: "/issues", element: {(p) => } }, { path: "*", element: }, ]; -- 2.47.2 From 57145b22c9ef8ad858d837bbde1723447f31f4be Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 21:32:09 +0400 Subject: [PATCH 11/16] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5:=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=B8=20=D1=84?= =?UTF-8?q?=D0=BB=D0=B0=D0=B3=D0=B8=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0,=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20?= =?UTF-8?q?(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 1 + web/src/board/BoardPage.test.tsx | 42 ++++++++++++++++++++++++++++- web/src/board/BoardPage.tsx | 2 +- web/src/board/model/fromHub.ts | 4 ++- web/src/board/model/model.test.ts | 11 +++++++- web/src/board/storage.test.ts | 30 ++++++++++++++++++++- web/src/board/storage.ts | 12 +++++++-- web/src/board/useBoardPrefs.ts | 8 ++++-- 8 files changed, 101 insertions(+), 9 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index b00b539..beeab1f 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -298,3 +298,4 @@ в `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` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа. diff --git a/web/src/board/BoardPage.test.tsx b/web/src/board/BoardPage.test.tsx index f735dca..afa9e82 100644 --- a/web/src/board/BoardPage.test.tsx +++ b/web/src/board/BoardPage.test.tsx @@ -26,7 +26,12 @@ function hub(over: Record Response> = {}) { return json(404, { error: "not_found" }); }); } -const page = () => renderWithProviders(, { route: "/board", path: "/board" }); +const page = (project = "AWPCA") => renderWithProviders(, { route: "/board", path: "/board" }); +// канбан-проект: спринтов нет ни у одной своей задачи (соседи — из другого проекта — могут иметь) +const withSprints = (keep: (key: string) => boolean) => ({ ...SNAP, issues: SNAP.issues.map((i: { key: string; sprint: string | null }) => ({ ...i, sprint: keep(i.key) ? i.sprint : null })) }); +const KANBAN = withSprints(() => false); +const NEIGHBOR_ONLY = withSprints((k) => k === "AURORA-9007"); +const NO_SPRINT_TEXT = "колонки «Спринт» нет — показаны все задачи"; describe("страница доски", () => { beforeEach(() => setToken("tok")); @@ -166,4 +171,39 @@ describe("страница доски", () => { expect(screen.getByRole("group", { name: "Все исполнители" })).toBeInTheDocument(); expect(screen.queryByRole("listbox")).toBeNull(); }); + + it("T9: старая раскладка переносится только в проект AWPCA", async () => { + hub(); + localStorage.setItem("graph-pos:old.json", '{"AURORA-9001":{"x":1,"ry":40}}'); + const r = page("STOCKS_DRRK"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board@STOCKS_DRRK")).toBeNull(); + expect(localStorage.getItem("graph-pos:board")).toBeNull(); + r.unmount(); + page("AWPCA"); + await screen.findByText(/^Хаб · данные на/); + expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-9001":{"x":1,"ry":40}}'); + }); + + it("T9: канбан-проект — без спринтов в снимке: надпись вместо фильтра спринтов", async () => { + hub({ "GET /board": () => json(200, KANBAN) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + }); + + it("T9: канбан-проект, спринт только у соседа — надпись, карточка соседа на доске", async () => { + hub({ "GET /board": () => json(200, NEIGHBOR_ONLY) }); + page("STOCKS_DRRK"); + expect(await screen.findByText(NO_SPRINT_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /AWPCA_Спринт/ })).toBeNull(); + expect(screen.getAllByText("AURORA-9007").length).toBeGreaterThan(0); + }); + + it("T9: проект AWPCA с фикстурой hub-basic — выбор спринта есть", async () => { + hub(); + page("AWPCA"); + expect(await screen.findByRole("button", { name: /AWPCA_Спринт 2/ })).toBeInTheDocument(); + expect(screen.queryByText(NO_SPRINT_TEXT)).toBeNull(); + }); }); diff --git a/web/src/board/BoardPage.tsx b/web/src/board/BoardPage.tsx index cda7acc..8ad3c78 100644 --- a/web/src/board/BoardPage.tsx +++ b/web/src/board/BoardPage.tsx @@ -21,7 +21,7 @@ export function BoardPage({ project }: { project: string }) { const closedStatuses = useClosedStatuses(); const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]); const src = useBoardSource(d && !d.missing.length ? d.dicts : null, project); - const prefs = useBoardPrefs(src.model); + const prefs = useBoardPrefs(src.model, project); const today = todayIso(); const vis = useMemo(() => computeVisible(src.model, prefs.filters, prefs.store.laneOrder, today), [src.model, prefs.filters, prefs.store.laneOrder, today]); diff --git a/web/src/board/model/fromHub.ts b/web/src/board/model/fromHub.ts index 9c1b442..6adee88 100644 --- a/web/src/board/model/fromHub.ts +++ b/web/src/board/model/fromHub.ts @@ -26,5 +26,7 @@ export function fromHub(snap: BoardSnapshot, dicts: Dicts): BoardModel { }); const missing = addStubs(nodes, links, NOT_IN_HUB); const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"]; - return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB }; + // фильтр спринтов — только по своим задачам: спринт соседа из другого проекта не делает канбан-проект «спринтовым» + const hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint); + return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint, missing, stubLane: NOT_IN_HUB }; } diff --git a/web/src/board/model/model.test.ts b/web/src/board/model/model.test.ts index 0842028..a6c1588 100644 --- a/web/src/board/model/model.test.ts +++ b/web/src/board/model/model.test.ts @@ -21,7 +21,16 @@ describe("модель доски = links.html@3931a88", () => { }); it("fromHub — пустое зеркало без полного прогона", () => { const snap = JSON.parse(fx("hub-empty.board.json")) as BoardSnapshot; - expect(canon(fromHub(snap, dicts))).toEqual(golden("hub-empty.expected.json").model); + // эталон написан, когда fromHub всегда давал hasSprint: true; без своих спринтов фильтр спринтов не показывается + expect(canon(fromHub(snap, dicts))).toEqual({ ...golden("hub-empty.expected.json").model, hasSprint: false }); + }); + it("T9: fromHub — спринта у своих задач нет (есть только у соседа) → hasSprint false", () => { + const snap = JSON.parse(fx("hub-basic.board.json")) as BoardSnapshot; + const none = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: null })) }; + expect(fromHub(none, dicts).hasSprint).toBe(false); + const neighborOnly = { ...snap, issues: snap.issues.map((i) => ({ ...i, sprint: i.neighbor ? i.sprint : null })) }; + expect(fromHub(neighborOnly, dicts).hasSprint).toBe(false); + expect(fromHub(snap, dicts).hasSprint).toBe(true); }); it("parseCsv и fromCsv — BOM, CRLF, кавычки, повторные колонки, даты разных локалей", () => { const text = fx("jira-export.csv"); diff --git a/web/src/board/storage.test.ts b/web/src/board/storage.test.ts index 0c926be..a8bbc98 100644 --- a/web/src/board/storage.test.ts +++ b/web/src/board/storage.test.ts @@ -11,7 +11,7 @@ import storageCases from "./__fixtures__/storage.cases.json"; import { computeLayout, measuredHeight } from "./layout/layout.ts"; import { fromHub } from "./model/fromHub.ts"; import { fromJson } from "./model/fromJson.ts"; -import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, toggleLane, writeFlag } from "./storage.ts"; +import { applyJsonSettings, colWidths, foldedSet, laneOrder, migrateMode, migrateStore, moveLane, readPrefs, resetLayout, saveLayout, savedLayout, setColWidth, setStoreProject, toggleLane, writeFlag } from "./storage.ts"; import type { BoardModel, JsonImport } from "./types.ts"; import { noFilters } from "./types.ts"; @@ -126,3 +126,31 @@ describe("операции хранилища", () => { expect(() => { toggleLane("A"); resetLayout(true); setColWidth("2026-10-20", 400); }).not.toThrow(); }); }); + +describe("ключи по проектам", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => setStoreProject(null)); + + it("AWPCA — прежние ключи", () => { + setStoreProject("AWPCA"); + writeFlag("crit", true); toggleLane("Эпик 1"); + expect(localStorage.getItem("graph-crit")).toBe("1"); + expect(localStorage.getItem("graph-fold:board")).toBe('["Эпик 1"]'); + }); + it("другой проект — ключи с суффиксом, AWPCA не тронут", () => { + localStorage.setItem("graph-pos:board", '{"AURORA-1":{"x":1,"ry":2}}'); + setStoreProject("STOCKS_DRRK"); + writeFlag("crit", true); toggleLane("Эпик 1"); setColWidth("2026-10-20", 400); + saveLayout({ "AURORA-2": { x: 10, ry: 20, w: 200, h: 80, auto: false } }, {}, false); + applyJsonSettings({ layout: { "AURORA-3": { x: 1, ry: 1 } }, layoutByDate: {}, laneOrder: ["A"], filters: { neighbors: true } } as unknown as JsonImport); + expect(localStorage.getItem("graph-crit@STOCKS_DRRK")).toBe("1"); + expect(localStorage.getItem("graph-fold:board@STOCKS_DRRK")).toBe('["Эпик 1"]'); + expect(localStorage.getItem("graph-lanes:board@STOCKS_DRRK")).toBe('["A"]'); + expect(localStorage.getItem("graph-neighbors@STOCKS_DRRK")).toBe("1"); + expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-1":{"x":1,"ry":2}}'); + expect(localStorage.getItem("graph-crit")).toBeNull(); + expect(laneOrder()).toEqual(["A"]); + setStoreProject("AWPCA"); + expect(laneOrder()).toEqual([]); + }); +}); diff --git a/web/src/board/storage.ts b/web/src/board/storage.ts index fbd132d..f0d5518 100644 --- a/web/src/board/storage.ts +++ b/web/src/board/storage.ts @@ -1,6 +1,8 @@ // хранилище настроек доски: ключи и формат — как у links.html@3931a88 (прежние настройки пользователя продолжают работать) // localStorage: общие настройки «graph-<имя>», настройки доски «graph-<имя>:board» (раскладка, дорожки, фильтры). Ключ не зависит от имени // файла: карточки адресуются ключами Jira, поэтому расстановка переживает замену данных. Любое обращение защищено от недоступного хранилища +// ключи по проектам (решения 10 и 20): раскладка и флаги доски — отдельно для каждого проекта; у AWPCA ключи прежние, у остальных — суффикс «@<метка>» +// (graph-pos:board@STOCKS_DRRK). Все функции модуля берут ключи через pref()/fileKey() и текущий набор (setStoreProject) import { COLW, GRID } from "./constants.ts"; import type { RelBox, Layout } from "./layout/layout.ts"; import type { Filters, JsonImport } from "./types.ts"; @@ -12,13 +14,19 @@ export const store = { del(k: string): void { try { localStorage.removeItem(k); } catch { /* хранилище недоступно */ } }, json(k: string, d: T): T { try { const v = JSON.parse(store.get(k, "null") as string); return v === null ? d : v as T; } catch { return d; } }, }; -export const pref = (name: string): string => "graph-" + name; +export const LEGACY_PROJECT = "AWPCA"; // у него ключи без суффикса — раскладка, сделанная до проектов, сохраняется +export function storeSuffix(label: string | null): string { return label === null || label === LEGACY_PROJECT ? "" : "@" + label; } +let suffix = ""; // набор ключей текущего проекта +export function setStoreProject(label: string | null): void { suffix = storeSuffix(label); } +export const isLegacyStore = (): boolean => suffix === ""; +export const pref = (name: string): string => "graph-" + name + suffix; const BOARD = "board", BOARD_KEYS = ["pos", "lanes", "fold", "sprint", "who"]; -export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD; +export const fileKey = (name: string): string => "graph-" + name + ":" + BOARD + suffix; export const strList = (v: unknown): string[] => Array.isArray(v) ? v.filter((s): s is string => typeof s === "string") : []; const isObj = (v: unknown): v is Record => !!v && typeof v === "object" && !Array.isArray(v); const num = (v: unknown): boolean => typeof v === "number" && Number.isFinite(v); +// миграции — только для набора AWPCA (isLegacyStore): у остальных проектов старых ключей не было // до 11.10.2026 настройки лежали под именем файла («graph-pos:<файл>») и терялись при каждой новой выгрузке; при первом запуске новой версии // переносим на общий ключ набор того файла, где расставлено больше всего карточек export function migrateStore(): void { diff --git a/web/src/board/useBoardPrefs.ts b/web/src/board/useBoardPrefs.ts index a97628f..13ccc73 100644 --- a/web/src/board/useBoardPrefs.ts +++ b/web/src/board/useBoardPrefs.ts @@ -23,8 +23,12 @@ const pick = (model: BoardModel, f: Filters) => ({ model, sprints: initialSprints(model, f, st.rememberedSprints()), who: initialWho(model, f, st.rememberedWho()) }); // настройки доски из хранилища links.html: флаги, скрытые виды и типы, выбор спринтов и исполнителей, раскладка -export function useBoardPrefs(model: BoardModel): BoardPrefsApi { - const [prefs, setPrefs] = useState(() => { st.migrateMode(); st.migrateStore(); return st.readPrefs(); }); +export function useBoardPrefs(model: BoardModel, project: string): BoardPrefsApi { + const [prefs, setPrefs] = useState(() => { + st.setStoreProject(project); // ключи проекта — до любого чтения; страница пересоздаётся при смене проекта (key={project}) + if (st.isLegacyStore()) { st.migrateMode(); st.migrateStore(); } + return st.readPrefs(); + }); const [store, setStore] = useState(() => readStore(0)); const [sel, setSel] = useState(() => pick(model, prefs.filters)); if (sel.model !== model) setSel(pick(model, prefs.filters)); // новая модель — выбор заново, как fillSprints/fillWho -- 2.47.2 From 1b441b79a57d16da7b385b935b6bf92774d03dba Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 21:41:22 +0400 Subject: [PATCH 12/16] =?UTF-8?q?=D0=A7=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=D1=85=D0=BE=D0=B4=D0=BE=D0=BA=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87:=20=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=8B=20=D0=B4=D0=BE=D1=81=D0=BE=D0=BA=20=D0=B2=20=D0=BB?= =?UTF-8?q?=D1=8E=D0=B1=D0=BE=D0=BC=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D0=B5,=20=D0=BC=D0=B5=D0=BB=D0=BA=D0=B8=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=B5=D0=B1-=D0=BF=D1=80=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R-3/T6.1 типы досок из результата refresh пишутся в любом статусе отчёта; T5.1, T3.1 (author), T3.2, T2.1, T2.2, T7.2, T8.1, T8.2 (ошибка фонового рефетча проектов не сносит страницу), T8.4 (окно «Новый проект» не закрывается во время создания). Решения 22, 27 переписаны, добавлено 36 в design.md. Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 16 ++++++---- src/jira_hub/api.py | 6 ++-- src/jira_hub/queue.py | 4 +-- tests/test_api.py | 26 +++++++++++++++- tests/test_mcp.py | 2 ++ tests/test_queue.py | 4 +-- tests/test_settings.py | 2 +- web/src/projects/NewProjectDialog.tsx | 4 +-- web/src/projects/projects.test.tsx | 45 +++++++++++++++++++++++++-- web/src/shell/Layout.tsx | 2 +- 10 files changed, 92 insertions(+), 19 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index beeab1f..ea9be1c 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -250,9 +250,9 @@ раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`. 21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске. -22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у - остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка - пишется автором `api`, а не отвергается. +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`. Цена ошибки: код ошибки в ответе. @@ -264,9 +264,11 @@ 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` пишутся только при `status = done`; результат без ключа `boards` - или с мусорными значениями типы не трогает.** Обоснование: у проваленной команды результат неполон, а агент v11 ключа - не присылает. Цена ошибки: одно условие в `report_result`. +27. 2026-10-19 — **типы досок из результата `refresh` пишутся в любом статусе отчёта (`done` и `failed`), если в + результате-словаре есть ключ `boards`; без ключа или с мусорными значениями типы не трогаются.** Обоснование: тип + доски — факт о доске, а не часть выборки: проваленная выборка задач не делает присланный тип неверным; спека + commands требует записать присланные типы по результату refresh. Поздний отчёт по уже терминальной команде типы не + пишет (ветка терминальной команды возвращается раньше). Цена ошибки: одно условие в `report_result`. 28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без обращения к зеркалу.** Обоснование: `new:` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта) единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`. @@ -299,3 +301,5 @@ показывает страницы; `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}».** + Обоснование: ошибка пакетная, а не одной команды. Цена ошибки: только текст сообщения. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index e277fb6..2292b57 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -418,8 +418,10 @@ def h_projects_get(hub, conn, m, q, body): def h_projects_post(hub, conn, m, q, body): """Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" - author = body.get("author") or "api" - if not isinstance(author, str): + 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): diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 94a786f..e1343e0 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -362,8 +362,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 status == "done" and isinstance(result, dict) and "boards" in result: - projects.record_board_types(conn, result["boards"]) # без ключа (агент v11) типы не трогаем + 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) diff --git a/tests/test_api.py b/tests/test_api.py index d945af3..3bfe694 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,6 +13,7 @@ import pytest from jira_hub import api, db, files, projects, queue, release, settings from jira_hub.client import HubClient, HubError +from jira_hub.commands import CommandError @pytest.fixture @@ -274,7 +275,8 @@ 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": []}, {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, + {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, + {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается ]) def test_settings_put_invalid_value_is_422(hub, body): @@ -812,6 +814,10 @@ def test_create_project_without_board(hub): ({"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"] @@ -823,6 +829,24 @@ def test_create_project_errors(hub, body, status, 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") diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 20d629c..dab86e3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -402,6 +402,8 @@ def test_unbound_session(live, monkeypatch): # сце 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("агент ") # служебные инструменты работают без привязки diff --git a/tests/test_queue.py b/tests/test_queue.py index b28c79e..a61b1f3 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -929,9 +929,9 @@ def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" -def test_refresh_failed_result_keeps_board_types(conn, add_project): +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"] is None + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" diff --git a/tests/test_settings.py b/tests/test_settings.py index 1f60b01..6a47edb 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -55,7 +55,7 @@ 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 and "base_jql" not in keys and "board_id" not in keys - assert settings.get(conn, "base_jql") is None and settings.get(conn, "board_id") is None + assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn) assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} assert settings.get(conn, "pacing") == settings.DEFAULT_PACING diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx index 576aaf8..5d4d176 100644 --- a/web/src/projects/NewProjectDialog.tsx +++ b/web/src/projects/NewProjectDialog.tsx @@ -22,7 +22,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr return ( { e.preventDefault(); onClose(); }}> + onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>

Новый проект

@@ -32,7 +32,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
- + {text && {text}}
diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index d7a0a06..9214398 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +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"; @@ -18,7 +18,7 @@ const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, const ROW = { ...SNAP.issues[1], fetched_at: "" }; interface Call { method: string; url: URL; body: unknown } -function hub(projects: unknown[] = [A, S], over: Record Response> = {}) { +function hub(projects: unknown[] = [A, S], over: Record Response | Promise> = {}) { const calls: Call[] = []; const fetch = mockFetch((method, url, body) => { calls.push({ method, url, body }); @@ -49,6 +49,7 @@ describe("проект в приложении", () => { 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"); @@ -59,6 +60,7 @@ describe("проект в приложении", () => { 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"); @@ -74,6 +76,7 @@ describe("проект в приложении", () => { 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 () => { @@ -135,6 +138,44 @@ describe("проект в приложении", () => { expect(dlg).toHaveAttribute("open"); }); + it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => { + let n = 0; + const h = hub([A, S], { + "GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })), + "POST /projects": () => json(201, { project: S, batch_id: 7 }), + }); + const router = open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает + await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + expect(h.get("/projects").length).toBeGreaterThan(1); + expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK"); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + }); + + it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } }); + open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled()); + expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled(); + fireEvent(dlg, new Event("cancel", { cancelable: true })); + expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument(); + release(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument()); + }); + it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => { const h = hub(); const router = open("/app/issues/AURORA-9001"); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3fe5e4f..f9ccdde 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -58,7 +58,7 @@ export function Layout() { const projects = useProjects(); const urlLabel = new URLSearchParams(pageLocation.search).get("project"); const state = useMemo(() => projects.isPending ? { kind: "loading" } - : projects.error ? { kind: "error", error: projects.error } + : 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; -- 2.47.2 From 9c2c4d09e056c578dd1afac7f2ee0c0ee0d53597 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 21:47:46 +0400 Subject: [PATCH 13/16] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D1=8B:=20e2e=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0,=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B2=20design.md=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 18 +++++---- CLAUDE.md | 4 +- README.md | 63 +++++++++++++++++++++++------ openspec/changes/projects/design.md | 41 +++++++++++++++++++ web/e2e/projects.spec.ts | 49 ++++++++++++++++++++++ 5 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 web/e2e/projects.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 988e97a..87600b0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,40 +1,42 @@ # Архитектура -Хаб моста Jira: SQLite-зеркало задач AURORA/AWPCA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. +Хаб моста Jira: SQLite-зеркало задач проектов-меток Jira-проекта AURORA, служебные заметки, очередь команд, HTTP API и MCP. Агент (`jira-agent`, виртуалка) опрашивает хаб, пишет в Jira и возвращает состояние; хаб Jira не вызывает. ## Модули | Модуль | Ответственность | Зависит от | |---|---|---| | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | -| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` | +| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | -| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | +| `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | +| `mirror` | зеркало задач, связей и комментариев, разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | -| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения), выдача (`claim`, `size` у `ensure_attachment` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `db` | +| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | | `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | | `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`, `files` (только `MAX_SIZE`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | `web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) -и «Задачи» (`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует +и «Задачи» (`/app/issues`); текущий проект — `?project=<метка>` в адресе (`web/src/projects/`: контекст проекта, выпадающий список «Проект», окно «Новый проект», заглушка «проект не найден»; страницы пересоздаются по `key={project}`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на `/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` -и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`. +и `storage.ts` (ключи `localStorage` прежней страницы; у проекта AWPCA — прежние, у остальных с суффиксом `@<метка>`; набор ключей — состояние модуля, выставляется при монтировании страницы), отрисовка — `web/src/board/components`. -Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. +- Создание проекта: `POST /projects` (веб или `project_create`) → `projects.create` и пакет «Новый проект <метка>» (`refresh` по общей JQL, `batch.project = NULL`) в одной транзакции → `claim` (хаб дописывает в `refresh` доски проектов, `boards`) → агент определяет тип доски → отчёт `refresh` пишет `board_type` → `ensure_sprint` проекта получает `board_id`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. diff --git a/CLAUDE.md b/CLAUDE.md index e3be21b..6d8c44a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # jira-hub ## Что это -Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проектов-меток Jira-проекта AURORA на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; активные change — `openspec/changes/` (кроме `archive/`). ## Стек и ограничения - Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. @@ -26,7 +26,7 @@ - `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. - Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`, задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). -- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены. +- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. - `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). ## Тесты diff --git a/README.md b/README.md index 5d139a9..692cce4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # jira-hub -Хаб моста Jira для АРМ КА: зеркало задач проекта AURORA (метка AWPCA) в SQLite, служебные заметки, очередь команд, +Хаб моста Jira для АРМ КА: зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.) в SQLite, служебные заметки, очередь команд, HTTP API в локальной сети и MCP-сервер для Claude. Хаб работает на Mac mini (`192.168.0.106`); в Jira ходит только агент на виртуалке (репозиторий `jira-agent`), пароль Jira хабу неизвестен. Спека: `openspec/changes/bridge-core/` (proposal, design, specs). @@ -59,9 +59,25 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj После перезапуска хаба или обновления кода MCP — `/mcp` → reconnect. +Сессия MCP работает в разрезе одного проекта-метки. Привязка — `--project <метка>` в `.mcp.json` репозитория проекта +(метка сравнивается точно, с учётом регистра): + +```json +{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", "/Users/andrey.epifancev/projects/jira-bridge/jira-hub", "jira-hub", "mcp", "--project", "AWPCA"]}}} +``` + +Без привязки (user scope, без `--project`) работают только служебные инструменты (`projects`, `project_create`, +`agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`); +`issues`, `issue`, `notes_set`, `batch_create`, `command_retry` и `refresh` +отвечают «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)». Метка, которой нет в +хабе, — «проект <метка> не заведён в хабе: project_create или исправьте --project в .mcp.json». В привязанной сессии +`issues`, `batches` и `notes_set` видят только свой проект (`notes_set` — свои задачи и временные ключи `new:`), +`issue` открывает любую задачу по ключу, пакеты и команды ставятся с проектом: хаб сам дописывает метку проекта в +`ensure_issue` и отвергает чужую метку. + | Инструмент | Параметры | Результат | |---|---|---| -| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач | +| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач проекта сессии | | `issue` | `key` | поля, связи, заметки, последние команды | | `notes_set` | `key`, поля заметок | обновлённые заметки | | `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд | @@ -70,18 +86,25 @@ claude mcp add -s user jira-hub -- uv run --project /Users/andrey.epifancev/proj | `batches` | `limit` | последние пакеты | | `command_retry`, `batch_cancel` | `id` | новый пакет / результат отмены | | `refresh` | `keys` или `full`, `wait` (по умолчанию `false`: только ставит пакет; `true` — ждёт до 30 мин) | id пакета или итог пакета | +| `projects` | нет | проекты хаба: метка, название, доска, тип доски, число задач; проект сессии отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | проект заведён, пакет обновления `#N`, готовый фрагмент `.mcp.json` для репозитория проекта | | `agent_status` | нет | онлайн/офлайн, версия, текущая команда, 429 за сутки | | `sync_log` | `limit` | последние загрузки состояния | | `settings_get`, `settings_set` | ключ, значение | значение / новая `settings_version` | -Автор команд из MCP: `claude`; из API без автора: `api`. +Автор команд из MCP: `claude`; из API без автора: `api`; из веб-приложения: `viewer`. + +В примере у `ensure_issue` нет `labels`: в привязанной сессии метку проекта дописывает хаб. Новый проект — `project_create`: +в ответе «проект <метка> «<название>» заведён, пакет обновления #N», затем указание положить в корень репозитория проекта +`.mcp.json` (слить с имеющимся) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта) и сам фрагмент `.mcp.json` +с командой `uv run --project <корень хаба> jira-hub mcp --project <метка>`. «Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`, загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла, нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится. ```json -[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}, +[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана"}}, {"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}}, {"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}] ``` @@ -150,18 +173,19 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py | `GET /agent/known-keys` | агент | ключи зеркала | | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | -| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | +| `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project` | +| `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`, все связи, `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,19 @@ 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`). + Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Шрифты (IBM Plex) входят в сборку: приложение работает без доступа в интернет. Раскладка доски и вход сохраняются: приложение читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб @@ -206,9 +243,9 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub | `ensure_comment` | `key`, `marker`, `body` | | `ensure_status` | `key`, `target` (имя статуса или список-путь) | | `ensure_link` | `source`, `target`, `link_type`, `replace` | -| `ensure_sprint` | `key`, `sprint` (имя или `backlog`) | +| `ensure_sprint` | `key`, `sprint` (имя или `backlog`); доску проекта задачи (`board_id`) дописывает хаб, от клиента параметр отвергается | | `ensure_attachment` | `key` (ключ или `new:`), `file` (sha256 из `POST /files`), `filename` (имя вложения в Jira, без `/`, `\`, `"` и управляющих символов); `size` хаб дописывает при выдаче агенту | -| `refresh` | `keys` или `jql` | +| `refresh` | `keys` или `jql`; при выдаче агенту хаб дописывает `boards` — доски проектов | Тип `bug` — «Ошибка» в Jira, обязательные поля те же, что у задачи. Ставится, только если в настройках есть `issue_types.bug` с непустым `name` или `id`, иначе 422 «команда N: нет настройки issue_types.bug». `init` берёт его из diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index ea9be1c..87b28ab 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -303,3 +303,44 @@ 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`; + эталон — снимок прежней страницы. Цена ошибки: эталон расходится с прежней страницей в одном поле. diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts new file mode 100644 index 0000000..9671c8c --- /dev/null +++ b/web/e2e/projects.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; +import { agentServeRefresh, login } from "./hub.ts"; + +// проект заводится один раз на тестовый хаб: сценарии идут по порядку, файл — последний по алфавиту +test.describe.serial("проекты", () => { + test("первый вход: проект AWPCA в адресе, на доске задача проекта", async ({ page }) => { + await login(page); + await expect(page).toHaveURL(/\/app\/board\?project=AWPCA$/); + await expect(page.locator('.board .card[data-key="AURORA-9001"]')).toBeVisible(); + }); + + test("метка занята: окно остаётся открытым, ошибка в alert", async ({ page }) => { + await login(page); + await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" }); + const d = page.getByRole("dialog", { name: "Новый проект" }); + await d.getByLabel("Метка").fill("awpca"); + await d.getByLabel("Название").fill("x"); + await d.getByRole("button", { name: "Создать" }).click(); + await expect(d.getByRole("alert")).toContainText("уже есть"); + await expect(d).toBeVisible(); + }); + + test("создание проекта из формы: адрес, пакет, разбор пакета агентом", async ({ page }) => { + await login(page); + await page.getByLabel("Проект", { exact: true }).selectOption({ label: "+ Новый проект…" }); + const d = page.getByRole("dialog", { name: "Новый проект" }); + await d.getByLabel("Метка").fill("STOCKS_DRRK"); + await d.getByLabel("Название").fill("Запасы ДРРК"); + await d.getByRole("button", { name: "Создать" }).click(); + await expect(page).toHaveURL(/\?project=STOCKS_DRRK/); + await expect(page.locator(".board .card[data-key]")).toHaveCount(0); + await expect(page.getByRole("status")).toContainText(/пакет #\d+/); + 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(); + }); +}); -- 2.47.2 From ec4fb8b1e74de96a23f1564eb8815f1471025462 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 22:06:41 +0400 Subject: [PATCH 14/16] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= =?UTF-8?q?:=20=D1=82=D0=B8=D0=BF=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0,=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20labels=5Fadd,=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: boards() отдаёт type=null, если тип неизвестен у любого проекта доски. M-2: labels_add ensure_fields нормализуется к метке проекта. M-3: light_list вместо list_projects в _assign_boards. M-4/T5.4: пустой project — 422. M-1, M-5, T10.1-T10.7: README, ARCHITECTURE, CLAUDE.md, design.md (49-51), e2e. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 6 +++--- CLAUDE.md | 4 ++-- README.md | 9 +++++++-- openspec/changes/projects/design.md | 14 +++++++++++++- src/jira_hub/api.py | 4 ++++ src/jira_hub/projects.py | 16 ++++++++++++---- src/jira_hub/queue.py | 8 +++++++- tests/test_api.py | 6 ++++++ tests/test_projects.py | 25 +++++++++++++++++++++++++ tests/test_queue.py | 6 ++++++ web/e2e/projects.spec.ts | 2 +- 11 files changed, 86 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 87600b0..4292119 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,11 +15,11 @@ | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | | `backup` | копии файла базы, ротация | `paths` | | `files` | хранилище файлов вложений `<данные>/files/` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` | | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `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` | @@ -46,7 +46,7 @@ - Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). -- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел +- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects` напрямую и ходит только через `client`; из `files` берёт только предел `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. diff --git a/CLAUDE.md b/CLAUDE.md index 6d8c44a..0352b92 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ ## Архитектура - `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром. -- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. +- `db` (схема, миграции) ← `settings`, `mirror`, `commands`, `projects` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`. - `files` (хранилище вложений `files/`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`. - `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы). - Подробная карта модулей и границ — `ARCHITECTURE.md`. @@ -24,7 +24,7 @@ - `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 [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены. - `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх). diff --git a/README.md b/README.md index 692cce4..173de8a 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,10 @@ 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 /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` | +| `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` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — задачи проекта и соседи по связям, с `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}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта | @@ -218,6 +218,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub исправляет только владелец, прямо в базе хаба: `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` (тестовый хаб diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 87b28ab..55ba5e3 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -230,7 +230,8 @@ `batch_status/wait/cancel` по id — без ограничения.** Обоснование: список не засоряется чужими пакетами, а явный id — осознанный запрос. Цена ошибки: фильтр списка. 14. 2026-10-19 — **миграция v4 не удаляет `base_jql` и `board_id` из `setting`; `board_id` остаётся запасной доской - агента; удаление — follow-up.** Обоснование: базу мигрирует любой процесс новой версии (MCP, CLI), возможно — под + агента; удаление — 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.** Обоснование: иначе прогон из очереди не признается полным. Цена ошибки: одна строка миграции. @@ -344,3 +345,14 @@ 48. 2026-10-19 — **эталон `hub-empty.expected.json` не перегенерируется (его пишет прежняя страница, где `hasSprint` всегда `true`): тест сравнивает с ним, подменив `hasSprint` на `false`.** Обоснование: решение 12 меняет смысл `hasSprint`; эталон — снимок прежней страницы. Цена ошибки: эталон расходится с прежней страницей в одном поле. +49. 2026-10-19 — **`boards()` отдаёт `type = null`, если тип неизвестен хотя бы у одного проекта доски (иначе — общий + известный тип; при расхождении — первый по порядку проектов).** Обоснование: спека «board_type только по результату + refresh»; агент типы известных досок не перезапрашивает, поэтому новый проект на уже известной доске оставался + с `board_type = NULL` навсегда, и его `ensure_sprint` получал 422; `record_board_types` проставляет тип всем проектам + доски. Цена ошибки: проект на известной доске без спринтов до ручной правки базы. +50. 2026-10-19 — **в пакете проекта метки `labels_add` у `ensure_fields`, равные P без учёта регистра, заменяются на P + (повтор P убирается), как в `ensure_issue`.** Обоснование: единообразие с `ensure_issue`; иначе в Jira уходит + вариант метки в чужом регистре. Цена ошибки: лишняя метка-двойник у задачи. +51. 2026-10-19 — **`queue._assign_boards` берёт проекты из `projects.light_list` (label, board_id, board_type) без + `issues_count`.** Обоснование: подсчёт задач по всему зеркалу при каждой постановке `ensure_sprint` не нужен. + Цена ошибки: нет (поведение и порядок те же). diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 2292b57..95362d0 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -288,6 +288,8 @@ def h_batches_post(hub, conn, m, q, body): 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, project=body.get("project")) @@ -334,6 +336,8 @@ def h_command_retry(hub, conn, m, q, body): 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, project=body.get("project")) diff --git a/src/jira_hub/projects.py b/src/jira_hub/projects.py index a231d32..9c6461d 100644 --- a/src/jira_hub/projects.py +++ b/src/jira_hub/projects.py @@ -74,6 +74,11 @@ def list_projects(conn) -> list[dict]: 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")] @@ -88,12 +93,15 @@ def base_jql(conn) -> str | None: def boards(conn) -> list[dict]: - """Доски проектов без повторов (порядок первого проекта); type — первый известный среди проектов доски.""" + """Доски проектов без повторов (порядок первого проекта). 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"): - if out.get(r["board_id"]) is None: - out[r["board_id"]] = r["board_type"] - return [{"id": i, "type": t} for i, t in out.items()] + 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: diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index e1343e0..a407b70 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -74,7 +74,7 @@ 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.list_projects(conn) + found = projects.light_list(conn) for i, c in enumerate(cmds, 1): if c["type"] != "ensure_sprint": continue @@ -127,9 +127,15 @@ def _check_project(conn, cmds, project) -> list[dict]: 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}) diff --git a/tests/test_api.py b/tests/test_api.py index 3bfe694..47d0f8a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -894,6 +894,9 @@ def test_project_batches_api(hub, add_project): # #61, T 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"]] @@ -907,6 +910,9 @@ def test_project_batches_api(hub, add_project): # #61, T 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" diff --git a/tests/test_projects.py b/tests/test_projects.py index 80251e2..d53b632 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -10,6 +10,31 @@ def conn(open_db): 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,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: diff --git a/tests/test_queue.py b/tests/test_queue.py index a61b1f3..f4227fe 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -701,6 +701,12 @@ def test_project_label_case_variant_replaced(conn, add_project): # сво 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"), # «Задача другого проекта» diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts index 9671c8c..6a06741 100644 --- a/web/e2e/projects.spec.ts +++ b/web/e2e/projects.spec.ts @@ -28,8 +28,8 @@ test.describe.serial("проекты", () => { await d.getByLabel("Название").fill("Запасы ДРРК"); await d.getByRole("button", { name: "Создать" }).click(); await expect(page).toHaveURL(/\?project=STOCKS_DRRK/); - await expect(page.locator(".board .card[data-key]")).toHaveCount(0); 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("выполнен"); }); -- 2.47.2 From 1f2fc142b73e67a1586981bd3b0bb8625fb81500 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 22:55:05 +0400 Subject: [PATCH 15/16] =?UTF-8?q?fix(hub):=20=D0=BC=D0=B5=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20JQL=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D0=B2=D1=8B=D1=87=D0=BA=D0=B0=D1=85,=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20\=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D1=8E=D1=89=D0=B8=D1=85=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2=20(I-2,=20#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 11 +++++----- openspec/changes/projects/proposal.md | 2 +- .../changes/projects/specs/data-model/spec.md | 21 +++++++++++++------ .../changes/projects/specs/hub-api/spec.md | 4 ++-- src/jira_hub/projects.py | 9 ++++---- tests/test_api.py | 11 ++++++---- tests/test_db.py | 2 +- tests/test_projects.py | 11 ++++++++-- tests/test_queue.py | 2 +- 9 files changed, 47 insertions(+), 26 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 55ba5e3..ade886f 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -38,13 +38,14 @@ Отдельная таблица, а не ключ настроек: проект — сущность со своим жизненным циклом, а настройки уходят агенту целиком и поднимают `settings_version` при каждой правке. Создание проекта `settings_version` не поднимает: агенту проекты не нужны, доски он получает в параметрах команд (решение 7). -2. **Метка проекта** — непустая строка без пробельных символов, запятых, кавычек и скобок (метка Jira не может - содержать пробел; остальное исключено, чтобы метку можно было без экранирования вставить в JQL `labels in (…)`), до - 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает +2. **Метка проекта** — непустая строка без пробельных и управляющих символов (категория Unicode `Cc`), запятых, + кавычек, скобок и обратной косой черты (метка Jira не может содержать пробел; остальное исключено, чтобы метку можно + было вставить в JQL в двойных кавычках без экранирования), до 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает метки без учёта регистра в JQL). Название — непустое после обрезки пробелов, до 100 символов. `board_id` — целое > 0 или отсутствует. -3. **Базовая JQL собирается, а не хранится.** `base_jql = "project = AND labels in (<метки по - created_at, затем label>)"`; функция в `settings` (или новом модуле `projects`), её используют `POST /refresh +3. **Базовая JQL собирается, а не хранится.** `base_jql = 'project = 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 «нет проектов», автообновление diff --git a/openspec/changes/projects/proposal.md b/openspec/changes/projects/proposal.md index e250c70..ac997e6 100644 --- a/openspec/changes/projects/proposal.md +++ b/openspec/changes/projects/proposal.md @@ -22,7 +22,7 @@ - **Проекты в хабе.** Новая таблица `project` (схема v4): метка Jira (ключ, не меняется), название, номер доски Jira (может отсутствовать), тип доски (`scrum` / `kanban`, определяет агент). Маршруты `GET /projects`, `POST /projects`. Миграция заводит проект AWPCA «АРМ КА» с доской 4216 из нынешних настроек. -- **Базовая JQL собирается из проектов** — `project = AND labels in (<метки всех проектов>)`; настройки +- **Базовая JQL собирается из проектов** — `project = AND labels in ("<метка>", …)`; настройки `base_jql` и `board_id` уходят из `setting`. Создание проекта ставит полный `refresh`. - **Разрез по проекту в API.** `GET /board?project=` и `GET /issues?project=` отдают задачи проекта и их соседей (задачи других меток, связанные с ними, и их эпики) с признаком `neighbor` относительно проекта. diff --git a/openspec/changes/projects/specs/data-model/spec.md b/openspec/changes/projects/specs/data-model/spec.md index 9503eaf..64668b1 100644 --- a/openspec/changes/projects/specs/data-model/spec.md +++ b/openspec/changes/projects/specs/data-model/spec.md @@ -3,12 +3,13 @@ ### Requirement: Проекты Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: `label` (PK, метка Jira), `name`, `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — -тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных символов, запятых, -кавычек и скобок; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть +тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория +Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: -`project = AND labels in (<метки проектов по created_at, затем label через ", ">)`; без проектов базовой JQL +`project = AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем +label, через `, `; без проектов базовой JQL нет (`null`). Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без @@ -17,7 +18,7 @@ #### Scenario: Базовая JQL из двух проектов - **WHEN** заведены проекты `AWPCA` (раньше) и `STOCKS_DRRK`, `project_key = AURORA` -- **THEN** базовая JQL — `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` +- **THEN** базовая JQL — `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` #### Scenario: Свои и соседи - **WHEN** у AURORA-1 метка `STOCKS_DRRK` и эпик AURORA-9 (метка `AWPCA`), связь AURORA-1 → AURORA-2 (метка `AWPCA`), @@ -28,6 +29,14 @@ - **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** отказ «проект с такой меткой уже есть», проект один @@ -71,12 +80,12 @@ - **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)`; задачи, заметки, + и `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)`, и по завершении он считается +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается полным прогоном #### Scenario: Два соединения к базе v1 diff --git a/openspec/changes/projects/specs/hub-api/spec.md b/openspec/changes/projects/specs/hub-api/spec.md index d5477a7..76f7a27 100644 --- a/openspec/changes/projects/specs/hub-api/spec.md +++ b/openspec/changes/projects/specs/hub-api/spec.md @@ -12,7 +12,7 @@ #### 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)"}` + с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` #### Scenario: Проект без доски - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` @@ -146,7 +146,7 @@ #### Scenario: Два проекта - **WHEN** интервал прошёл, очередь пуста, проекты `AWPCA` и `STOCKS_DRRK` -- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` +- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` ### Requirement: Снимок доски Хаб SHALL предоставлять `GET /board?project=` под клиентским токеном — согласованный снимок зеркала для доски связей, diff --git a/src/jira_hub/projects.py b/src/jira_hub/projects.py index 9c6461d..7e0d4bb 100644 --- a/src/jira_hub/projects.py +++ b/src/jira_hub/projects.py @@ -1,6 +1,7 @@ """Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4.""" import logging import re +import unicodedata from . import db, settings @@ -10,7 +11,7 @@ LEGACY_LABEL = "AWPCA" LEGACY_NAME = "АРМ КА" MAX_LEN = 100 BOARD_TYPES = ("scrum", "kanban") -_FORBIDDEN = ',"\'()' +_FORBIDDEN = ',"\'()\\' _FIELDS = "label, name, board_id, board_type, created_at" @@ -32,8 +33,8 @@ def _label_error(label) -> str | None: return "ожидается непустая строка" if len(label) > MAX_LEN: return f"длиннее {MAX_LEN} символов" - if any(c.isspace() or c in _FORBIDDEN for c in label): - return "пробелы и символы , \" ' ( ) недопустимы" + if any(c.isspace() or c in _FORBIDDEN or unicodedata.category(c) == "Cc" for c in label): + return "пробелы, управляющие символы и символы , \" ' ( ) \\ недопустимы" return None @@ -89,7 +90,7 @@ def base_jql(conn) -> str | None: if not found: return None key = settings.get(conn, "project_key") or "AURORA" - return f"project = {key} AND labels in ({', '.join(found)})" + return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" def boards(conn) -> list[dict]: diff --git a/tests/test_api.py b/tests/test_api.py index 47d0f8a..07ee54b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -131,7 +131,7 @@ def test_interface_routes(hub, add_project): 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 in (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"]} @@ -552,7 +552,7 @@ 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)" + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA")' def test_settings_put_returns_all_settings(hub): @@ -794,9 +794,9 @@ def test_create_project(hub): # сце 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 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)" + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' def test_create_project_without_board(hub): @@ -811,6 +811,9 @@ def test_create_project_without_board(hub): ({"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), diff --git a/tests/test_db.py b/tests/test_db.py index d871074..27e8753 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -257,7 +257,7 @@ 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)" + 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" # ключи на месте diff --git a/tests/test_projects.py b/tests/test_projects.py index d53b632..12ce18b 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -35,7 +35,7 @@ def test_light_list_same_order_no_count(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,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None]) +@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, "Название") @@ -85,11 +85,18 @@ def test_same_label_other_case(conn): 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)" + 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") diff --git a/tests/test_queue.py b/tests/test_queue.py index f4227fe..9fde9fe 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -170,7 +170,7 @@ def test_auto_refresh_two_projects(conn, add_project): # сцен 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)"} + 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): -- 2.47.2 From 0ca8678000113b05888fa0c229786311ec8502d2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 22:55:37 +0400 Subject: [PATCH 16/16] =?UTF-8?q?=D0=A1=D0=BF=D0=B5=D0=BA=D0=B0=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=B0:=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0,=20=D1=81=D1=82=D0=B0=D0=B2=D1=88=D0=B0=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=B0=D0=BD=D0=B1=D0=B0=D0=BD=D0=BE=D0=BC,=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?(R-36=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/specs/agent/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/projects/specs/agent/spec.md b/openspec/changes/projects/specs/agent/spec.md index c3332a2..a5ac2ba 100644 --- a/openspec/changes/projects/specs/agent/spec.md +++ b/openspec/changes/projects/specs/agent/spec.md @@ -6,7 +6,7 @@ `GET /rest/agile/1.0/board/{id}` и вернуть в результате команды `boards: {"": "scrum" | "kanban" | null}` (`null` — доска не найдена, нет доступа или тип не `scrum`/`kanban`; причина — в лог агента; `refresh` продолжается). Имена спринтов задач агент SHALL собирать со всех досок типа `scrum` (известного или только что определённого), доски типа -`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Команда без `board_id`/`boards` +`kanban` и `null` пропускать; задача в спринтах двух досок получает последнее найденное имя (как в v11). Если доска, которую хаб передал как `scrum`, не отдала спринты, агент SHALL перепроверить её тип: стала `kanban` — доска пропускается, в результате `boards: {"": "kanban"}` (хаб записывает тип и при прежнем `scrum`); иначе `refresh` — `failed` с текстом «доска : …» (агент v12, R-36). Команда без `board_id`/`boards` (хаб до проектов) SHALL выполняться по `settings.board_id`, как в v11. `ensure_sprint` с `board_id` канбан-доски агент не получает (хаб отказывает при постановке); если Jira всё же ответила, что у доски нет спринтов, — `failed` с текстом Jira. -- 2.47.2