Доска связей на живых данных хаба (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 | | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` | | `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры | `db` | | `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
| `backup` | копии файла базы, ротация | `paths` | | `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` | | `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`) | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio | `client` | | `mcp_server` | инструменты MCP по stdio | `client` |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens` | `api`, `mcp_server`, `importer`, `settings`, `db`, `paths` | | `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`. Цепочки: `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-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). - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10).
@ -22,6 +22,7 @@
- `uv sync` — установка окружения. - `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба. - `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` — показать токены. - `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 ## 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 /agent/known-keys` | агент | ключи зеркала |
| `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` | | `GET /issues` | клиент | фильтры `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком | | `GET /issues/{key}` | клиент | задача целиком |
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
| `PUT /issues/{key}/note` | клиент | заметки (частично) | | `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд |
| `POST /batches/{id}/cancel` | клиент | отмена пакета | | `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" 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 на запрос). """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос).
Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).""" Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md).
Страница доски (GET /, GET /links) — без токена: статический файл без данных."""
import hmac import hmac
import json import json
import logging import logging
@ -9,6 +10,7 @@ import time
import traceback import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from . import backup, db, mirror, queue, relay, settings from . import backup, db, mirror, queue, relay, settings
@ -20,9 +22,16 @@ from .queue import ClaimedError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
class ApiError(Exception): class ApiError(Exception):
def __init__(self, status, error, detail=None): def __init__(self, status, error, detail=None):
super().__init__(error) 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") 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): def h_note_put(hub, conn, m, q, body):
fields = {k: v for k, v in body.items() if k != "updated_by"} fields = {k: v for k, v in body.items() if k != "updated_by"}
try: try:
@ -245,6 +262,7 @@ def h_settings_put(hub, conn, m, q, body):
ROUTES = [ ROUTES = [
("GET", r"^/(links)?$", h_page, None),
("POST", r"^/agent/claim$", h_claim, "agent"), ("POST", r"^/agent/claim$", h_claim, "agent"),
("GET", r"^/agent/settings$", h_agent_settings, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"),
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "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"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"), ("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"), ("GET", r"^/batches$", h_batches_get, "client"),
@ -313,7 +332,8 @@ class Handler(BaseHTTPRequestHandler):
match = rx.match(url.path) match = rx.match(url.path)
if not match: if not match:
continue continue
self._auth(scope) if scope is not None:
self._auth(scope)
try: try:
body = json.loads(raw.decode("utf-8")) if raw else {} body = json.loads(raw.decode("utf-8")) if raw else {}
except ValueError as e: # битый JSON или не UTF-8 except ValueError as e: # битый JSON или не UTF-8
@ -334,10 +354,16 @@ class Handler(BaseHTTPRequestHandler):
self._send(500, {"error": "internal", "detail": str(e)}) self._send(500, {"error": "internal", "detail": str(e)})
def _send(self, status, payload): def _send(self, status, payload):
data = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") 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_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))) self.send_header("Content-Length", str(len(data)))
for name, value in extra.items():
self.send_header(name, value)
self.end_headers() self.end_headers()
self.wfile.write(data) self.wfile.write(data)

View File

@ -91,6 +91,34 @@ def get_issue(conn, key) -> dict | None:
return d 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: def get_note(conn, key) -> dict | None:
row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone() row = conn.execute("SELECT * FROM issue_note WHERE key = ?", (key,)).fetchone()
return dict(row) if row else None 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 json
import threading import threading
import urllib.error
import urllib.request
import pytest 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:1", "issue_type": "task", "summary": "Шахматка: комментарии"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "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 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") mirror.rename_note(conn, "new:6", "A-6")
note = mirror.get_note(conn, "A-6") note = mirror.get_note(conn, "A-6")
assert note["rest_text"] == "доделать отчёт" and note["done_text"] == "сделано" 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}