Доска связей на живых данных хаба (viewer-live, #17) #18

Merged
aep merged 11 commits from feat/17-viewer-live into main 2026-10-14 17:29:06 +03:00
13 changed files with 2664 additions and 8 deletions

View File

@ -9,17 +9,22 @@
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` | `queue`, `mirror`, `settings`, `backup`, `relay` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board | `queue`, `mirror`, `settings`, `backup`, `relay` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` |
`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
веб-приложение.
Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
## Потоки данных

View File

@ -1,7 +1,7 @@
# jira-hub
## Что это
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущего change нет (последний — `openspec/changes/archive/2026-10-14-bridge-hygiene/`).
Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/viewer-live/`.
## Стек и ограничения
- Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
@ -22,6 +22,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/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены.
## Тесты

View File

@ -98,7 +98,8 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j
## HTTP API
Все маршруты требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422).
Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422).
| Маршрут | Токен | Назначение |
|---|---|---|
@ -110,6 +111,8 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j
| `GET /agent/known-keys` | агент | ключи зеркала |
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком |
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд |
| `POST /batches/{id}/cancel` | клиент | отмена пакета |
@ -123,6 +126,19 @@ TOKEN=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=Спринт%202&limit=20"
```
## Доска связей
`http://127.0.0.1:8790/` — доска «Спринт 2 линк» на данных зеркала: дорожки по эпикам, стрелки блокировок, режим
«по датам», критический путь, проблемы. При первом входе спрашивает клиентский токен (`uv run jira-hub tokens`) и
хранит его в браузере; `localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои.
- «Перечитать» — снимок зеркала заново, без Jira; «Обновить из Jira» — пакет `refresh` по базовой JQL (автор
`viewer`), страница ждёт его и перечитывает доску; в шапке — время последнего полного обновления.
- Раскладка, дорожки и фильтры хранятся в браузере. Перенос из старой страницы: там «⋯ → Экспорт JSON», здесь «файл…»
→ этот JSON: применяются раскладка и фильтры, задачи остаются из хаба.
- «файл…» с CSV из фильтра Jira — снимок файла до «Перечитать» (запасной путь, если хаб недоступен).
- «⋯ → Сменить токен хаба» — после пересоздания токенов.
## Контракт команд
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -0,0 +1,98 @@
## Context
Просмотрщик «Спринт 2 линк» — один HTML-файл (~117 КБ, из них ~19 КБ — встроенная выгрузка CSV от 11.10.2026) без
зависимостей: разбор CSV фильтра Jira (`fromJiraCsv`) → модель `{nodes, links, sprints}` → доска (дорожки по эпикам,
карточки, стрелки блокировок, режим «по датам», критический путь, панель проблем, таблица), раскладка и фильтры —
в `localStorage` под ключами `graph-<имя>:board`, не зависящими от источника; экспорт/импорт JSON (`arm-ka-links/1`).
Модель узла: `{key, title, kind, prio, due, status, epic, sprint, who}`; связи `{src, dst, type}` с логическими типами
«блокирует начало / блокирует завершение / связанные» (`LINK_TYPES` переводит имена Jira `ASUPR_*`).
Хаб (`main` fc732eb): зеркало `issue` содержит все нужные поля (`planned_end`, `epic_key`, `labels`, `sprint`, `assignee`,
`issue_type`), связи — `issue_link (source_key, target_key, link_type)` с семантикой Jira, заметки — `issue_note`,
журнал синхронизаций — `sync_run`. HTTP — `http.server`, маршруты `ROUTES` с областью токена, ответы только JSON (`_send`).
`GET /issues` постраничный и без связей.
## Goals / Non-Goals
**Goals:** доска на живых данных хаба на Mac mini без ручной выгрузки; обновление из Jira кнопкой; перенос раскладки
старой страницы; API, пригодный будущему React-приложению.
**Non-Goals:** запись из доски (`viewer-edit`), React-приложение, доступ из сети, изменение логики доски.
## Decisions
1. **Одна страница — статика хаба.** Файл `src/jira_hub/web/links.html` входит в пакет (hatch включает файлы каталога
пакета), читается при запросе через `Path(__file__).parent / "web" / "links.html"`. Маршруты `GET /` и `GET /links`
без области токена (в `ROUTES` — область `None`, `_auth` не вызывается); ответ — `text/html; charset=utf-8`,
`Cache-Control: no-cache` (страница меняется вместе с хабом). В `api` — отправка не-JSON ответа (обработчик возвращает
`Raw(content_type, bytes)`).
2. **`GET /board` — сырой снимок, а не модель доски.** Перевод в модель (вид работы по меткам `kind-*`, имена эпиков,
логические типы связей) остаётся на странице — там он уже есть для CSV, и React-приложению нужен тот же нейтральный
формат. Чтение — в одной транзакции (`BEGIN` на чтение: задачи, связи и `last_sync` из одного момента). Функция
`mirror.board(conn) -> dict`; `api` только оборачивает.
3. **`last_sync` — последний завершённый полный прогон:** `sync_run` с `kind = 'jql'`, `query` = текущая `base_jql`,
`finished_at` не пуст, по `started_at` последний. Обновления по ключам (хвостовые `refresh` пакетов) в «данные на» не
считаются: они обновляют часть задач.
4. **Вход по токену в браузере.** Токен — в `localStorage["jira-hub-token"]`, заголовок `Authorization: Bearer` в каждом
`fetch` к тому же хосту; 401 → форма токена с пояснением. Токен не попадает в HTML, URL и логи хаба.
5. **«Обновить из Jira» через существующие маршруты.** `GET /agent` (офлайн → предупреждение, пакет всё равно ставится),
`POST /refresh {full: true, author: "viewer"}`, опрос `GET /batches/{id}` каждые 3 с до терминального статуса пакета,
затем `GET /board`. Кнопка недоступна, пока пакет идёт; перезагрузка страницы во время пакета не теряет его — пакет
выполнится, «Перечитать» покажет результат.
6. **Модель доски из снимка (`fromHub`).** Эпики (`issue_type` «Фича»/Epic) — имена дорожек (`epic_key` → `summary`), сами
карточками не становятся; остальные задачи, включая соседей, — карточки; вид работы — первая метка из `KIND_LABELS`,
иначе `issue_type`; срок — `planned_end`, иначе `duedate`; связи — только типы из `LINK_TYPES`, `src = source`,
`dst = target`; ключи из связей, которых нет в зеркале, — заглушки на дорожке «Нет в зеркале». Имя источника — «Хаб».
7. **Импорт JSON в режиме хаба** сохраняет раскладку, дорожки и фильтры (как сейчас) и вместо показа снимка из JSON
перечитывает `GET /board`. Загрузка CSV — снимок файла с пометкой «из файла»; «Перечитать» возвращает данные хаба.
8. **Встроенная выгрузка удаляется** (`DEFAULT_CSV`, `DEFAULT_NAME` и их использование в `migrateStore`): без хаба
страница показывает причину и предлагает загрузить файл. Константа `EPICS` остаётся запасным словарём имён для CSV.
9. **Проверка страницы — сценарий в браузере.** Тестовой обвязки для JS в хабе нет; страница проверяется прогоном через
Playwright на хабе с тестовой базой (`JIRA_HUB_HOME` во временном каталоге, зеркало заполнено через `POST /agent/state`)
и затем на живом хабе: вход, доска, «Обновить из Jira», «Перечитать», импорт JSON, неверный токен, остановленный хаб.
Сторона хаба — pytest.
## Risks / Trade-offs
- [Снимок всего зеркала на каждый запрос] → ~200 задач и сотни связей — десятки КБ; при росте — фильтр по спринту в
параметрах (не сейчас).
- [Токен в `localStorage` доступен скриптам страницы] → страница своя, сторонних скриптов нет; доступ только с Mac mini.
- [Расхождение `KIND_LABELS`/`LINK_TYPES` страницы с настройками хаба (`kind_labels`, `link_types`)] → словари совпадают
на 14.10.2026; синхронизация из `/settings` — в React-приложении.
- [Страница отдаётся без токена] → в ней нет данных; все данные — через API с токеном.
## Migration Plan
1. Merge → `git pull` в `jira-hub` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub`.
2. Открыть `http://127.0.0.1:8790/`, ввести клиентский токен (`uv run jira-hub tokens`).
3. В старой странице «Спринт 2 линк» — «экспорт JSON», в новой — загрузить этот JSON: раскладка применится к живым данным.
4. Откат: предыдущий `main` и перезапуск; старая страница в `admin/` работает как раньше.
## Open Questions
Нет.
## Decisions made without discussion
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-1 (design #2) | 2026-10-14 | `GET /board` отдаёт сырые данные зеркала, перевод в модель доски — на странице | нейтральный формат нужен React-приложению; разбор уже есть на странице | лишний перевод на клиенте — дёшево переделать |
| R-2 (design #3) | 2026-10-14 | «Данные на» — время последнего полного прогона, обновления по ключам не учитываются | частичное обновление не делает всю доску свежей | владелец может видеть время старше последних изменений по отдельным задачам |
| R-3 (design #5) | 2026-10-14 | Пакет обновления — автор `viewer`, опрос каждые 3 с | автор отличает пакеты доски от `claude`/`hub`; 3 с — ощутимо и не грузит хаб | нет |
| R-4 (design #6) | 2026-10-14 | Соседи по связям — обычные карточки, заглушки только для ключей вне зеркала | у соседей в зеркале полные данные | соседей без спринта скрывает фильтр спринта, как и раньше |
| R-5 (design #8) | 2026-10-14 | Встроенная выгрузка от 11.10.2026 удаляется | устаревшие данные вводят в заблуждение; файл худеет на ~19 КБ | без хаба доска пуста до загрузки файла |
| R-6 (design #3) | 2026-10-14 | `last_sync` учитывает только полный прогон, команда которого `done`; `failed` и отменённый прогон (у них тоже есть `finished_at`) не считаются | неудачный прогон не обновил данные — «данные на» не должно сдвигаться | нет: неудачный прогон виден в журнале синхронизаций |
| R-7 (design #5) | 2026-10-14 | После перезагрузки страница находит незавершённый пакет с автором `viewer` (`GET /batches?limit=20`) и продолжает показывать его ход; кнопка «Обновить из Jira» недоступна | без этого перезагрузка позволяет поставить второй полный прогон подряд | лишний запрос при открытии страницы |
| R-8 (design #6) | 2026-10-14 | Связи, у которых один из концов — эпик, на доске не показываются | эпик — дорожка, а не карточка; иначе он появился бы заглушкой «нет в зеркале», хотя в зеркале есть | связь задачи с эпиком не видна на доске (видна в Jira) |
| R-9 (design #7) | 2026-10-14 | Импорт JSON при недоступном хабе показывает снимок из файла с пометкой «из файла»; при 401 — форма входа | спека требует запасной путь без хаба; снимок из JSON — то, что у пользователя есть | нет |
| R-10 (design #1) | 2026-10-14 | В страницу добавлены `<!doctype html>` и `<html lang="ru">` | старая страница писалась под обёртку артефакта, которая добавляет doctype; без него браузер включает режим совместимости | возможны мелкие отличия вёрстки от артефакта — проверяется скриншотами |
| R-11 (design #4) | 2026-10-14 | Пункт меню «Сменить токен хаба»; токен перед сохранением проверяется на печатные ASCII-символы | после пересоздания токенов нужен выход без очистки хранилища вручную; `fetch` с не-ASCII заголовком падает как сетевая ошибка и дал бы ложное «хаб не отвечает» | нет |
| R-12 (design #7) | 2026-10-14 | Пометка «(из файла)» и для CSV, и для JSON (вместо «(из JSON)»); ссылка загрузки «заменить…» переименована в «файл…» | спека говорит «из файла»; в режиме хаба загрузка файла не заменяет источник | нет |
| R-13 (design #5) | 2026-10-14 | Неудачное «Перечитать» при показанной доске оставляет доску и пишет предупреждение; пустая доска с причиной — только если показывать нечего | стирать рабочую доску из-за перезапуска хаба неудобно | пользователь может не заметить, что данные не обновились — предупреждение в шапке |
| R-14 (design #4) | 2026-10-14 | Текст при 401: «Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.» | для только что введённого неверного токена прежнее «возможно, его пересоздали» сбивало | нет |
| R-15 (design #1) | 2026-10-14 | `<link rel="icon" href="data:,">` и `.bar>span:empty{display:none}` | хаб отвечал 404 на `/favicon.ico` (ошибка в консоли на каждом открытии); пустые списки спринта/исполнителя давали отступ в строке фильтров | нет |
| R-16 (design #8) | 2026-10-14 | Пустая доска без хаба: «Нет данных: <причина>. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV…» | прежний шаблон давал «Хаб не отвечает (хаб не отвечает)» | нет |
| R-17 (design #7) | 2026-10-14 | Импорт JSON при недоступном хабе пишет в шапке «хаб не отвечает — показан снимок из <файл>» | текст `loadHub` «на доске прежние данные» неверен после замены доски снимком | нет |
| R-18 (design #5) | 2026-10-14 | Строка состояния хаба переносится (`#hubState{white-space:normal}`) | предупреждение «агент офлайн — …; пакет #N…» без переноса вылезало бы за край на узком окне | нет |
| R-19 (design #7) | 2026-10-14 | Подсказка окна экспорта JSON ссылается на «файл…» вместо «заменить…» | следствие R-12 | нет |
| R-20 (design #6) | 2026-10-14 | Связи из зеркала берутся только между ключами вида `AURORA-1234` (`KEY_RE`), как в разборе CSV/JSON (финальное ревью, I-1) | ключ связи попадает в разметку карточки; строка зеркала с разметкой в `target_key` исполнялась на странице с токеном | связь с ключом нестандартного вида не видна на доске |

View File

@ -0,0 +1,41 @@
## Why
Просмотрщик «Спринт 2 линк» (`admin/sprint-2-links.html`, артефакт на claude.ai) — граф блокировок спринта с раскладкой
по эпикам и датам, критическим путём и панелью проблем — работает на ручной выгрузке CSV из фильтра Jira. С 13.10.2026
в работе мост jira-hub/jira-agent: зеркало задач, связей и заметок обновляется из Jira командой `refresh` через очередь.
Ручная выгрузка CSV больше не нужна: доска может читать зеркало хаба напрямую.
Решения владельца 14.10.2026 (brainstorming):
- вариант A: сначала живые данные на текущей одностраничной версии, затем отдельным change — веб-приложение на React
(React + TypeScript + Vite, статика отдаётся хабом), где доска станет одной из страниц; API проектируется под него;
- страницу открывают пока только на Mac mini; вход — клиентский токен хаба, вводится один раз и хранится в браузере
(вариант 1; без исключений для 127.0.0.1 и без вшивания токена в страницу);
- раскладка из старой страницы переносится существующими экспортом/импортом JSON.
## What Changes
- **jira-hub**:
- `GET /board` (клиентский токен) — согласованный снимок зеркала: задачи (включая эпики и соседей) с заметками
`fact_status`/`work_kind`, связи, время последнего полного прогона; данные без преобразований;
- `GET /` и `GET /links` — страница доски связей без токена (статический файл из пакета `src/jira_hub/web/links.html`);
- страница — развитие `admin/sprint-2-links.html`: вход по токену, загрузка из `GET /board`, «Обновить из Jira»
(пакет `refresh` с автором `viewer` и ожидание его), «Перечитать», импорт JSON в режиме хаба применяет только
раскладку и фильтры; встроенная выгрузка CSV от 11.10.2026 удаляется; CSV-файл — запасной путь.
- Спека `hub-api` — MODIFIED «Процесс и доступ» (исключение для статической страницы), ADDED «Снимок доски» и «Страница
доски связей».
## Non-goals
- Запись из страницы (перенос в спринт, срок, связи, исполнитель командами очереди) — change `viewer-edit`.
- Веб-приложение на React — следующий change; эта страница в нём будет перенесена, не расширена.
- Доступ со других машин сети, вход без токена, хранение раскладки в базе хаба.
- Изменение логики доски (граф, раскладка, критический путь, проблемы, таблица, тема).
- Переопубликация артефакта на claude.ai (спека migration: не переопубликовывается).
## Impact
- `jira-hub`: `api.py` (маршруты `/board`, `/`, `/links`; отправка не-JSON ответа), новый модуль чтения снимка
(или функция в `mirror`), `src/jira_hub/web/links.html`, тесты API, `ARCHITECTURE.md`, `CLAUDE.md`, README.
- `admin/sprint-2-links.html` остаётся в `admin/` как история, не правится.
- Контракт с агентом не меняется. Новых зависимостей нет.
- Issue: aep/jira-hub#17.

View File

@ -0,0 +1,91 @@
## MODIFIED Requirements
### Requirement: Процесс и доступ
`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:<port>` (по умолчанию 8790), база и настройки в
`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать
`Authorization: Bearer <token>`: агентский токен открывает только `/agent/*`, клиентский — всё остальное. Исключение —
страница доски связей (`GET /`, `GET /links`): это статический файл без данных, он отдаётся без токена. Ответы API —
JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или `Content-Length`), 401, 404, 409, 413 (тело больше
предела), 422, 500 (внутренняя ошибка); реле общей папки дополнительно отвечает 502. Методы, кроме GET, POST и PUT,
отвечают 501 стандартной страницей `http.server`, не JSON.
#### Scenario: Чужой токен
- **WHEN** агентским токеном вызван `GET /issues`
- **THEN** 401
#### Scenario: Неподдерживаемый метод
- **WHEN** `DELETE /issues/AURORA-1`
- **THEN** 501, тело не JSON; база не меняется
#### Scenario: Страница без токена
- **WHEN** `GET /` без заголовка `Authorization`
- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — страница доски связей; данных зеркала в ней нет
## ADDED Requirements
### Requirement: Снимок доски
Хаб SHALL предоставлять `GET /board` под клиентским токеном — согласованный снимок зеркала для доски связей, прочитанный
в одной транзакции:
- `generated_at` — момент снимка (UTC);
- `last_sync` — последний завершённый полный прогон (`refresh` по базовой JQL): `{started_at, finished_at, issues_count}`
или `null`, если такого не было;
- `issues` — все задачи зеркала, включая эпики и соседей: `key`, `summary`, `issue_type`, `status`, `priority`,
`assignee`, `sprint`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (список), `neighbor` (bool),
`fact_status`, `work_kind` (из заметок, `null` если заметки нет); порядок — по ключу;
- `links` — все связи зеркала: `{source, target, link_type}`, `link_type` — имя типа в Jira, направление как в
`issue_link` (`source` блокирует/ссылается на `target`).
Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело интерфейса; хаб отдаёт данные
зеркала без преобразований.
#### 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** в журнале синхронизаций полный прогон, завершённый в 10:00, и после него обновление по ключам в 11:00
- **THEN** `last_sync.finished_at` — 10:00
#### Scenario: Без токена
- **WHEN** `GET /board` без токена или с агентским токеном
- **THEN** 401
### Requirement: Страница доски связей
Хаб SHALL отдавать по `GET /` и `GET /links` страницу доски связей — один HTML-файл из пакета хаба
(`src/jira_hub/web/links.html`, развитие просмотрщика «Спринт 2 линк»). Страница SHALL:
- спрашивать клиентский токен хаба при первом открытии и после ответа 401, хранить его в `localStorage` браузера и
передавать в заголовке `Authorization` своих запросов к API того же хоста;
- при открытии загружать `GET /board` и строить доску из него: задачи-эпики — дорожки, остальные задачи (включая
соседей) — карточки; вид работы — по меткам `kind-*`, иначе по типу задачи; срок карточки — `planned_end`, при его
отсутствии `duedate`; связи — по известным странице типам Jira, неизвестные типы пропускаются;
- показывать в шапке время последнего полного прогона (`last_sync.finished_at`);
- по кнопке «Обновить из Jira» ставить `POST /refresh {full: true, author: "viewer"}`, показывать ход пакета по
`GET /batches/{id}` до завершения и затем перечитывать `GET /board`; если агент офлайн (`GET /agent`), предупреждать,
но пакет ставить; при ошибке пакета — показывать её текст;
- по кнопке «Перечитать» загружать `GET /board` заново без обращения к Jira;
- сохранять раскладку карточек, порядок и свёртку дорожек, фильтры в `localStorage` под ключами, не зависящими от
источника данных; импорт JSON, выгруженного страницей, в режиме хаба SHALL сохранять эти настройки и сразу
перечитывать данные хаба, а не показывать снимок из файла;
- оставлять загрузку CSV из фильтра Jira как запасной путь (снимок с пометкой «из файла»; «Перечитать» возвращает данные
хаба);
- при недоступном хабе показывать понятное сообщение и предлагать загрузить файл; встроенной выгрузки данных страница
не содержит.
#### Scenario: Первый вход
- **WHEN** страница открыта впервые, токена в `localStorage` нет
- **THEN** вместо доски — поле клиентского токена с подсказкой `uv run jira-hub tokens`; после ввода верного токена доска
строится из `GET /board`, токен сохранён
#### Scenario: Неверный токен
- **WHEN** сохранённый токен устарел и `GET /board` ответил 401
- **THEN** страница снова показывает поле токена с пояснением, что токен не подошёл
#### Scenario: Обновление из Jira
- **WHEN** нажата «Обновить из Jira», агент на связи
- **THEN** в хабе пакет из одного `refresh` по базовой JQL с автором `viewer`; страница показывает «пакет #N выполняется»,
по завершении перечитывает доску и показывает новое время данных
#### Scenario: Перенос раскладки
- **WHEN** в режиме хаба импортирован JSON, выгруженный старой страницей «Спринт 2 линк»
- **THEN** раскладка, дорожки и фильтры из JSON применены к задачам хаба; задачи и связи — из хаба, не из JSON

View File

@ -1,5 +1,6 @@
"""HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md)."""
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
import hmac
import json
import logging
@ -9,6 +10,7 @@ import time
import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings
@ -20,9 +22,16 @@ from .queue import ClaimedError
log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
class ApiError(Exception):
def __init__(self, status, error, detail=None):
super().__init__(error)
@ -138,6 +147,14 @@ def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
def h_board(hub, conn, m, q, body):
return 200, mirror.board(conn, settings.get(conn, "base_jql"))
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"}
try:
@ -245,6 +262,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [
("GET", r"^/(links)?$", h_page, None),
("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
@ -253,6 +271,7 @@ ROUTES = [
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"),
@ -313,6 +332,7 @@ class Handler(BaseHTTPRequestHandler):
match = rx.match(url.path)
if not match:
continue
if scope is not None:
self._auth(scope)
try:
body = json.loads(raw.decode("utf-8")) if raw else {}
@ -334,10 +354,16 @@ class Handler(BaseHTTPRequestHandler):
self._send(500, {"error": "internal", "detail": str(e)})
def _send(self, status, payload):
if isinstance(payload, Raw):
ctype, data, extra = payload.content_type, payload.data, {"Cache-Control": "no-cache"}
else:
ctype, extra = "application/json; charset=utf-8", {}
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
for name, value in extra.items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(data)

View File

@ -91,6 +91,34 @@ def get_issue(conn, key) -> dict | None:
return d
BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", "planned_start",
"planned_end", "epic_key", "labels", "neighbor"]
def board(conn, base_jql) -> dict:
"""Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний
успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски
делает интерфейс. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы."""
own = not conn.in_transaction
if own:
conn.execute("BEGIN")
try:
issues = [_issue_dict(r) for r in conn.execute(
f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind "
"FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")]
links = [dict(r) for r in conn.execute(
"SELECT source_key AS source, target_key AS target, link_type FROM issue_link "
"ORDER BY source_key, target_key, link_type")]
run = conn.execute(
"SELECT s.started_at, s.finished_at, s.issues_count FROM sync_run s JOIN command c ON c.id = s.command_id "
"WHERE s.kind = 'jql' AND s.query = ? AND s.finished_at IS NOT NULL AND c.status = 'done' "
"ORDER BY s.started_at DESC, s.id DESC LIMIT 1", (base_jql,)).fetchone()
finally:
if own:
conn.execute("COMMIT")
return {"generated_at": db.now(), "last_sync": dict(run) if run else None, "issues": issues, "links": links}
def get_note(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
return dict(row) if row else None

1217
src/jira_hub/web/links.html Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
import json
import threading
import urllib.error
import urllib.request
import pytest
@ -339,3 +341,56 @@ def test_duplicate_summary_batch_is_422(hub):
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": "Шахматка: комментарии"}}]})
assert e.value.status == 422 and "Шахматка: комментарии" in e.value.detail
def test_board_route(hub):
a, c = hub["agent"], hub["client"]
a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": ["kind-dev"]},
{"key": "B-1", "summary": "Сосед", "labels": []}],
"links": [{"source_key": "A-1", "target_key": "B-1", "link_type": "ASUPR_Связанные"}],
"neighbor_keys": ["B-1"]})
snap = c.get("/board")
assert [i["key"] for i in snap["issues"]] == ["A-1", "B-1"]
assert snap["issues"][0]["labels"] == ["kind-dev"] and snap["issues"][1]["neighbor"] is True
assert snap["links"] == [{"source": "A-1", "target": "B-1", "link_type": "ASUPR_Связанные"}]
assert snap["last_sync"] is None
b = c.post("/refresh", {"full": True, "author": "viewer"})
cmd = a.post("/agent/claim", {})["command"]
a.post("/agent/state", {"command_id": cmd["id"], "issues": [{"key": "A-1", "summary": "Т2", "labels": []}], "links": []})
a.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1})
assert c.get(f"/batches/{b['id']}")["author"] == "viewer"
last = c.get("/board")["last_sync"]
assert last["issues_count"] == 1 and last["finished_at"]
for client in (a, HubClient(hub["base"], "")):
with pytest.raises(HubError) as e:
client.get("/board")
assert e.value.status == 401
def _raw_get(base, path):
with urllib.request.urlopen(base + path, timeout=10) as resp:
return resp.status, resp.headers, resp.read().decode("utf-8")
def test_page_served_without_token(hub):
hub["agent"].post("/agent/state", {"issues": [{"key": "ZZTEST-9001", "summary": "тема из зеркала", "labels": []}],
"links": []})
for path in ("/", "/links"):
status, headers, body = _raw_get(hub["base"], path)
assert status == 200
assert headers["Content-Type"] == "text/html; charset=utf-8"
assert headers["Cache-Control"] == "no-cache"
assert body.lstrip().lower().startswith("<!doctype html>")
assert "ZZTEST-9001" not in body and "тема из зеркала" not in body
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(hub["base"] + "/links/x", timeout=10)
assert e.value.code == 404
def test_page_reads_hub_and_has_no_embedded_export():
html = api.PAGE.read_text(encoding="utf-8")
assert "DEFAULT_CSV" not in html and "Ключ проблемы,Тема" not in html # встроенной выгрузки нет
assert '"jira-hub-token"' in html and 'hubFetch("/board")' in html
assert "Нет в зеркале" in html
assert 'author: "viewer"' in html and "/batches/" in html
assert "KEY_RE.test(l.source) && KEY_RE.test(l.target)" in html # I-1: ключи связей из зеркала идут в разметку карточек

View File

@ -135,3 +135,48 @@ def test_rename_note_empty_string_does_not_overwrite():
mirror.rename_note(conn, "new:6", "A-6")
note = mirror.get_note(conn, "A-6")
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано"
BASE = "project = AURORA AND labels = AWPCA"
def _sync_run(conn, started, finished, kind, query, status="done", count=5):
"""Строка журнала синхронизаций с командой refresh в нужном статусе."""
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('t', 'test', ?)", (started,))
batch_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at) VALUES (?, 1, 'refresh', '{}', ?, ?)",
(batch_id, status, finished))
command_id = conn.execute("SELECT last_insert_rowid() AS i").fetchone()["i"]
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, issues_count, command_id) VALUES (?, ?, ?, ?, ?, ?)",
(started, finished, kind, query, count, command_id))
def test_board_snapshot_raw_mirror_data():
conn = db.connect(":memory:")
mirror.apply_state(conn, [issue("AURORA-1351", issue_type="Фича", epic_key=None),
issue("AURORA-1620", labels=["kind-dev"], epic_key="AURORA-1351"),
issue("AURORA-1621", epic_key="AURORA-1351")],
[{"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}],
neighbor_keys=["AURORA-1621"])
mirror.set_note(conn, "AURORA-1620", {"work_kind": "разработка"}, "test")
snap = mirror.board(conn, BASE)
assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"]
a = snap["issues"][1]
assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate",
"planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"}
assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351"
assert a["work_kind"] == "разработка" and a["fact_status"] is None and a["neighbor"] is False
assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None
assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}]
assert snap["last_sync"] is None and snap["generated_at"]
def test_board_last_sync_is_last_successful_full_refresh():
conn = db.connect(":memory:")
_sync_run(conn, "2026-10-14T09:59:00+00:00", "2026-10-14T10:00:00+00:00", "jql", BASE, count=198)
_sync_run(conn, "2026-10-14T10:59:00+00:00", "2026-10-14T11:00:00+00:00", "keys", "AURORA-1620", count=1)
_sync_run(conn, "2026-10-14T11:30:00+00:00", "2026-10-14T11:31:00+00:00", "jql", "key = AURORA-1", count=1)
_sync_run(conn, "2026-10-14T12:00:00+00:00", "2026-10-14T12:01:00+00:00", "jql", BASE, status="failed", count=0)
_sync_run(conn, "2026-10-14T13:00:00+00:00", None, "jql", BASE, status="claimed", count=50)
assert mirror.board(conn, BASE)["last_sync"] == {"started_at": "2026-10-14T09:59:00+00:00",
"finished_at": "2026-10-14T10:00:00+00:00", "issues_count": 198}