From a6a9e5d4d68954429a8c51f7a67f8399d093b9e5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 16:00:57 +0400 Subject: [PATCH 01/11] =?UTF-8?q?docs(openspec):=20change=20viewer-live=20?= =?UTF-8?q?=E2=80=94=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20=D1=81=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=B5=D0=B9=20=D0=BD=D0=B0=20=D0=B6=D0=B8=D0=B2=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D1=85=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- openspec/changes/viewer-live/.openspec.yaml | 2 + openspec/changes/viewer-live/design.md | 83 +++++++++++++++++ openspec/changes/viewer-live/proposal.md | 41 +++++++++ .../changes/viewer-live/specs/hub-api/spec.md | 91 +++++++++++++++++++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/viewer-live/.openspec.yaml create mode 100644 openspec/changes/viewer-live/design.md create mode 100644 openspec/changes/viewer-live/proposal.md create mode 100644 openspec/changes/viewer-live/specs/hub-api/spec.md diff --git a/CLAUDE.md b/CLAUDE.md index a28d36c..b71736c 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/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). diff --git a/openspec/changes/viewer-live/.openspec.yaml b/openspec/changes/viewer-live/.openspec.yaml new file mode 100644 index 0000000..49ea85b --- /dev/null +++ b/openspec/changes/viewer-live/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-14 diff --git a/openspec/changes/viewer-live/design.md b/openspec/changes/viewer-live/design.md new file mode 100644 index 0000000..7bfdc40 --- /dev/null +++ b/openspec/changes/viewer-live/design.md @@ -0,0 +1,83 @@ +## Context + +Просмотрщик «Спринт 2 линк» — один HTML-файл (~117 КБ, из них ~100 КБ — встроенная выгрузка 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 удаляется | устаревшие данные вводят в заблуждение; файл худеет со ~117 до ~17 КБ | без хаба доска пуста до загрузки файла | diff --git a/openspec/changes/viewer-live/proposal.md b/openspec/changes/viewer-live/proposal.md new file mode 100644 index 0000000..a61d2f2 --- /dev/null +++ b/openspec/changes/viewer-live/proposal.md @@ -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. diff --git a/openspec/changes/viewer-live/specs/hub-api/spec.md b/openspec/changes/viewer-live/specs/hub-api/spec.md new file mode 100644 index 0000000..16fea4f --- /dev/null +++ b/openspec/changes/viewer-live/specs/hub-api/spec.md @@ -0,0 +1,91 @@ +## MODIFIED Requirements + +### Requirement: Процесс и доступ +`jira-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в +`~/.local/share/jira-hub/` (переопределяется `JIRA_HUB_HOME`). Все маршруты API SHALL требовать +`Authorization: Bearer `: агентский токен открывает только `/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 -- 2.47.2 From ad48db2929d559ed56510370296cee2697b21f68 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 16:01:11 +0400 Subject: [PATCH 02/11] =?UTF-8?q?docs(openspec):=20viewer-live=20=E2=80=94?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=BC=D0=B5=D1=80=20=D0=B2=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=B2=D1=8B=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/viewer-live/design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/viewer-live/design.md b/openspec/changes/viewer-live/design.md index 7bfdc40..06d8110 100644 --- a/openspec/changes/viewer-live/design.md +++ b/openspec/changes/viewer-live/design.md @@ -1,6 +1,6 @@ ## Context -Просмотрщик «Спринт 2 линк» — один HTML-файл (~117 КБ, из них ~100 КБ — встроенная выгрузка CSV от 11.10.2026) без +Просмотрщик «Спринт 2 линк» — один HTML-файл (~117 КБ, из них ~19 КБ — встроенная выгрузка CSV от 11.10.2026) без зависимостей: разбор CSV фильтра Jira (`fromJiraCsv`) → модель `{nodes, links, sprints}` → доска (дорожки по эпикам, карточки, стрелки блокировок, режим «по датам», критический путь, панель проблем, таблица), раскладка и фильтры — в `localStorage` под ключами `graph-<имя>:board`, не зависящими от источника; экспорт/импорт JSON (`arm-ka-links/1`). @@ -80,4 +80,4 @@ | 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 удаляется | устаревшие данные вводят в заблуждение; файл худеет со ~117 до ~17 КБ | без хаба доска пуста до загрузки файла | +| R-5 (design #8) | 2026-10-14 | Встроенная выгрузка от 11.10.2026 удаляется | устаревшие данные вводят в заблуждение; файл худеет на ~19 КБ | без хаба доска пуста до загрузки файла | -- 2.47.2 From cc7b83fbb85124c4f6c06a31188a517a7a48906b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 14 Oct 2026 17:50:03 +0400 Subject: [PATCH 03/11] =?UTF-8?q?docs:=20=D0=BF=D0=BB=D0=B0=D0=BD=20viewer?= =?UTF-8?q?-live=20=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=8D=D1=82=D0=B0=D0=BF=D0=B0=20=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= =?UTF-8?q?=20R-6=E2=80=A6R-13=20(#17)?= 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-14-viewer-live-hub.md | 1031 +++++++++++++++++ openspec/changes/viewer-live/design.md | 8 + 2 files changed, 1039 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-14-viewer-live-hub.md diff --git a/docs/superpowers/plans/2026-10-14-viewer-live-hub.md b/docs/superpowers/plans/2026-10-14-viewer-live-hub.md new file mode 100644 index 0000000..6772794 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-viewer-live-hub.md @@ -0,0 +1,1031 @@ +# viewer-live — хаб: план реализации + +> **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:** доска связей «Спринт 2 линк» открывается с хаба (`http://127.0.0.1:8790/`) и показывает живые данные зеркала: +вход по клиентскому токену, снимок `GET /board`, «Перечитать», «Обновить из Jira» с ожиданием пакета, перенос раскладки +импортом JSON, CSV — запасной путь. + +**Architecture:** хаб получает два маршрута: `GET /board` (`mirror.board` — снимок в одной транзакции чтения) и `GET /`, +`GET /links` без токена (статический файл `src/jira_hub/web/links.html`, не-JSON ответ `Raw`). Страница — копия +`admin/sprint-2-links.html`, к которой добавлены вход, загрузка из хаба и обновление; логика доски не меняется. +Первый коммит страницы — копия как есть, чтобы дальнейшие правки читались диффом. + +**Tech Stack:** Python 3.13, uv, pytest, SQLite (stdlib), stdlib `http.server`; страница — один HTML без зависимостей +(ванильный JS, `fetch`, `localStorage`); проверка страницы — Playwright (MCP) и `node --check`. + +**Spec:** `openspec/changes/viewer-live/` (proposal.md, design.md, specs/hub-api/spec.md). + +## Global Constraints + +- Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/viewer` (ветка `feat/17-viewer-live`). Все + команды — с этим cwd; основной checkout `jira-hub/` (main) и `admin/sprint-2-links.html` не правятся (из `admin/` только + копирование в Task 2). +- Комментарии, docstring, тексты страницы, сообщения ошибок и коммитов — по-русски; идентификаторы — английские. +- `uv run pytest -q` — зелёным считается только весь набор (до начала: 206 passed). +- Новых зависимостей нет; миграции схемы нет (`SCHEMA_VERSION` остаётся 2); контракт агента не меняется. +- Ключ токена в браузере — ровно `localStorage["jira-hub-token"]`; заголовок — `Authorization: Bearer <токен>`; запросы + страницы — относительные пути (тот же хост). +- «Обновить из Jira» — ровно `POST /refresh {"full": true, "author": "viewer"}`; опрос `GET /batches/{id}` каждые 3000 мс. +- Страница не содержит данных зеркала и встроенной выгрузки (`DEFAULT_CSV`, `DEFAULT_NAME` удаляются); `EPICS`, + `KIND_LABELS`, `LINK_TYPES` остаются. +- Служебная дорожка ключей вне зеркала — ровно «Нет в зеркале»; заглушка — «нет в зеркале». +- Логика доски (раскладка, критический путь, проблемы, таблица, тема, ключи хранилища `graph-*`) не меняется. +- Каждая задача заканчивается коммитом с заголовком на русском, суффиксом `(#17)` и строкой + `Co-Authored-By: Claude Opus 5.5 `. +- Решения исполнителя без обсуждения — в `openspec/changes/viewer-live/design.md`, раздел + `## Decisions made without discussion`, со следующим свободным маркером (занято R-1…R-13). +- Клиентский токен **живого** хаба не печатать и не вводить в браузер в ходе проверок до merge: проверки идут на + тестовом хабе и на копии базы с пересозданными токенами. + +## Review Focus + +1. Токен вставлен с пробелом/переводом строки или с не-ASCII символом (кириллица из буфера): `fetch` с таким заголовком + бросает `TypeError`, страница не должна сообщать «хаб не отвечает» — поле токена показывает «недопустимые символы». + Проверка — шаг Playwright в Task 3. +2. Хаб остановлен во время опроса пакета: страница не падает и не бросает опрос, а пишет предупреждение и повторяет; + после запуска хаба доводит пакет и перечитывает доску. Проверка — шаг Playwright в Task 4. +3. Токен пересоздан, пока страница открыта (401 на «Перечитать» или посреди опроса): форма входа с пояснением, без + зацикливания запросов. Проверка — шаги Playwright в Task 3 и Task 4. +4. В зеркале связь неизвестного типа (`Cloners`), связь с эпиком и связь на ключ вне зеркала: первые две пропускаются, + третья даёт заглушку на дорожке «Нет в зеркале». Проверка — фикстура учебного агента и шаг Playwright в Task 3. +5. Тема задачи с HTML-разметкой и кавычками (`разметка & "кавычки"`): показывается текстом, не разметкой. + Проверка — фикстура учебного агента и шаг Playwright в Task 3. + +## Проверочный стенд (Task 3–5) + +Тестовый хаб и учебный агент живут в рабочем каталоге плана `WS=$(/.superpowers/sdd/2026-10-14-viewer-live-hub)` +(git-ignored). Учебный агент — файл `$WS/fake_agent.py` (создаётся в Task 3, шаг 1, не коммитится): + +```python +"""Учебный агент для проверки страницы доски: берёт команды тестового хаба и отвечает загрузкой фикстуры. +Запуск (JIRA_HUB_HOME — каталог тестового хаба): uv run python fake_agent.py http://127.0.0.1:8791 [--once] [--fail]""" +import sys +import time + +from jira_hub import db, paths, settings +from jira_hub.client import HubClient + +ISSUES = [ + {"key": "AURORA-1351", "issue_type": "Фича", "summary": "Справочники ГК / ГСК / ГСЗ / ЮЛ", "labels": ["AWPCA"]}, + {"key": "AURORA-1620", "issue_type": "Задача", "summary": 'ТЕСТ доски: разметка & "кавычки"', + "status": "В работе", "priority": "Высокий", "assignee": "Epifantsev-AA@rshbavt.ru", "sprint": "AWPCA_Sprint_2_VGO", + "planned_end": "2026-10-21", "duedate": "2026-10-25", "epic_key": "AURORA-1351", "labels": ["AWPCA", "kind-dev"]}, + {"key": "AURORA-1621", "issue_type": "Задача", "summary": "ТЕСТ доски: блокируемая", "status": "Ожидание разработки", + "priority": "Средний", "assignee": "Stepanov-MAl", "sprint": "AWPCA_Sprint_2_VGO", "duedate": "2026-10-23", + "epic_key": "AURORA-1351", "labels": ["AWPCA", "kind-ui"]}, + {"key": "AURORA-1500", "issue_type": "Ошибка", "summary": "ТЕСТ доски: сосед без спринта", "status": "Готово", + "priority": "Низкий", "labels": []}, +] +LINKS = [ + {"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}, + {"source_key": "AURORA-1621", "target_key": "AURORA-1500", "link_type": "ASUPR_Связанные"}, + {"source_key": "AURORA-1620", "target_key": "AURORA-9999", "link_type": "ASUPR_Блокер_Завершения"}, + {"source_key": "AURORA-1620", "target_key": "AURORA-1621", "link_type": "Cloners"}, + {"source_key": "AURORA-1621", "target_key": "AURORA-1351", "link_type": "ASUPR_Связанные"}, +] + + +def main(): + base, once, fail = sys.argv[1], "--once" in sys.argv, "--fail" in sys.argv + conn = db.connect(paths.db_path()) + token = settings.tokens(conn)["agent"] + conn.close() + agent = HubClient(base, token, timeout=90) + n = 0 + while True: + cmd = agent.post("/agent/claim", {"agent_version": 4})["command"] + if not cmd: + if once: + return + continue + n += 1 + time.sleep(4) # страница успевает показать «выполняется» + if fail: + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "failed", "error": "учебная ошибка: Jira ответила 503"}) + else: + issues = [dict(i) for i in ISSUES] + issues[2]["summary"] = f"ТЕСТ доски: блокируемая (прогон {n})" + agent.post("/agent/state", {"command_id": cmd["id"], "issues": issues, "links": LINKS, + "neighbor_keys": ["AURORA-1500"]}) + agent.post(f"/agent/commands/{cmd['id']}/result", {"status": "done", "jira_calls": 1}) + print("команда", cmd["id"], cmd["type"], "→", "failed" if fail else "done", flush=True) + if once: + return + + +if __name__ == "__main__": + main() +``` + +Запуск тестового хаба (фоном) и заполнение зеркала одним полным прогоном: + +```bash +WS=$(pwd)/.superpowers/sdd/2026-10-14-viewer-live-hub +export JIRA_HUB_HOME=$WS/hub-test +uv run jira-hub serve --host 127.0.0.1 --port 8791 # фоном (run_in_background) +CT=$(uv run jira-hub tokens | sed -n 's/^клиентский токен: //p') +curl -s -X POST -H "Authorization: Bearer $CT" -H 'Content-Type: application/json' \ + -d '{"full": true, "author": "seed"}' http://127.0.0.1:8791/refresh +uv run python $WS/fake_agent.py http://127.0.0.1:8791 --once +``` + +Токен тестового хаба одноразовый — его можно вводить в браузер Playwright. Страница: `http://127.0.0.1:8791/`. + +--- + +### Task 1: `GET /board` — снимок зеркала для доски + +**Files:** +- Modify: `src/jira_hub/mirror.py` (новая функция `board` и список `BOARD_FIELDS` после `get_issue`) +- Modify: `src/jira_hub/api.py` (обработчик `h_board`, строка в `ROUTES`) +- Test: `tests/test_mirror.py`, `tests/test_api.py` + +**Interfaces:** +- Consumes: `mirror._issue_dict(row)` (labels → список, neighbor → bool), `settings.get(conn, "base_jql")`, `db.now()`. +- Produces: `mirror.board(conn, base_jql: str) -> dict` с ключами `generated_at: str`, `last_sync: dict | None` + (`started_at`, `finished_at`, `issues_count`), `issues: list[dict]` (поля `BOARD_FIELDS` + `fact_status`, + `work_kind`), `links: list[dict]` (`source`, `target`, `link_type`); маршрут `GET /board` (клиентский токен) отдаёт + его как есть. Страница (Task 3) читает именно эти имена. + +- [ ] **Step 1: Написать падающие тесты `mirror.board`** + +В конец `tests/test_mirror.py`: + +```python +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} +``` + +- [ ] **Step 2: Запустить — тесты падают** + +Run: `uv run pytest tests/test_mirror.py -q -k board` +Expected: 2 failed, `AttributeError: module 'jira_hub.mirror' has no attribute 'board'` + +- [ ] **Step 3: Реализовать `mirror.board`** + +В `src/jira_hub/mirror.py` после `get_issue`: + +```python +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} +``` + +- [ ] **Step 4: Запустить — тесты проходят** + +Run: `uv run pytest tests/test_mirror.py -q -k board` +Expected: 2 passed + +- [ ] **Step 5: Написать падающий тест маршрута** + +В конец `tests/test_api.py`: + +```python +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 +``` + +- [ ] **Step 6: Запустить — тест падает** + +Run: `uv run pytest tests/test_api.py -q -k board_route` +Expected: 1 failed, `HubError: HTTP 404 not_found: /board` + +- [ ] **Step 7: Добавить маршрут** + +В `src/jira_hub/api.py` после `h_issue`: + +```python +def h_board(hub, conn, m, q, body): + return 200, mirror.board(conn, settings.get(conn, "base_jql")) +``` + +В `ROUTES` после строки `("GET", r"^/issues/([^/]+)$", h_issue, "client"),`: + +```python + ("GET", r"^/board$", h_board, "client"), +``` + +- [ ] **Step 8: Весь набор зелёный** + +Run: `uv run pytest -q` +Expected: 209 passed + +- [ ] **Step 9: Коммит** + +```bash +git add src/jira_hub/mirror.py src/jira_hub/api.py tests/test_mirror.py tests/test_api.py +git commit -m "feat(api): GET /board — снимок зеркала для доски связей (#17) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: страница доски — статический файл хаба без токена + +**Files:** +- Create: `src/jira_hub/web/links.html` (копия `admin/sprint-2-links.html` + doctype) +- Modify: `src/jira_hub/api.py` (`PAGE`, `Raw`, `h_page`, маршрут с областью `None`, `_dispatch`, `_send`) +- Test: `tests/test_api.py` + +**Interfaces:** +- Consumes: — +- Produces: `api.PAGE: Path` (путь к странице; Task 3 читает его в статической проверке), `api.Raw(content_type, data)` + — не-JSON ответ обработчика; маршруты `GET /`, `GET /links` без токена, `Content-Type: text/html; charset=utf-8`, + `Cache-Control: no-cache`. + +- [ ] **Step 1: Скопировать страницу как есть и закоммитить отдельно** + +```bash +mkdir -p src/jira_hub/web +cp /Users/andrey.epifancev/projects/awp-ca/admin/sprint-2-links.html src/jira_hub/web/links.html +git add src/jira_hub/web/links.html +git commit -m "chore(web): страница «Спринт 2 линк» как есть — основа доски хаба (#17) + +Копия admin/sprint-2-links.html от 11.10.2026 без правок, чтобы дальнейшие изменения читались диффом. + +Co-Authored-By: Claude Opus 5.5 " +``` + +- [ ] **Step 2: Написать падающий тест** + +В `tests/test_api.py` к импортам добавить `import urllib.error` и `import urllib.request`; в конец файла: + +```python +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("") + 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 +``` + +- [ ] **Step 3: Запустить — тест падает** + +Run: `uv run pytest tests/test_api.py -q -k page_served` +Expected: 1 failed, `urllib.error.HTTPError: HTTP Error 404` (маршрута `/` ещё нет) + +- [ ] **Step 4: Добавить doctype в страницу** + +Первые строки `src/jira_hub/web/links.html` сейчас: + +```html + +Спринт 2 линк +``` + +Заменить на: + +```html + + + +Спринт 2 линк +``` + +- [ ] **Step 5: Реализовать отдачу страницы** + +В `src/jira_hub/api.py`: к импортам добавить `from typing import NamedTuple`; после `SOCKET_TIMEOUT = …`: + +```python +PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос + + +class Raw(NamedTuple): + """Не-JSON ответ обработчика (страница доски).""" + content_type: str + data: bytes +``` + +После `h_board`: + +```python +def h_page(hub, conn, m, q, body): + return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes()) +``` + +В `ROUTES` первой строкой (область `None` — без токена): + +```python + ("GET", r"^/(links)?$", h_page, None), +``` + +Docstring модуля дополнить: `Страница доски (GET /, GET /links) — без токена: статический файл без данных.` + +В `Handler._dispatch` заменить `self._auth(scope)` на: + +```python + if scope is not None: + self._auth(scope) +``` + +`Handler._send` заменить на: + +```python + 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", 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) +``` + +- [ ] **Step 6: Запустить — тест проходит; весь набор зелёный** + +Run: `uv run pytest -q` +Expected: 210 passed + +- [ ] **Step 7: Страница попадает в колесо пакета** + +Run: `uv build --wheel -o $WS/dist -q && unzip -l $WS/dist/jira_hub-*.whl | grep web/links.html` +(`WS=$(pwd)/.superpowers/sdd/2026-10-14-viewer-live-hub`) +Expected: одна строка `jira_hub/web/links.html` + +- [ ] **Step 8: Коммит** + +```bash +git add src/jira_hub/api.py src/jira_hub/web/links.html tests/test_api.py +git commit -m "feat(api): страница доски связей по GET / и /links без токена (#17) + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: страница — вход по токену и доска из `GET /board` + +**Files:** +- Modify: `src/jira_hub/web/links.html` +- Test: `tests/test_api.py` (статическая проверка страницы); Playwright-сценарий на тестовом хабе + +**Interfaces:** +- Consumes: `GET /board` (Task 1: `issues[].{key, summary, issue_type, status, priority, assignee, sprint, duedate, + planned_end, epic_key, labels}`, `links[].{source, target, link_type}`, `last_sync.{started_at, finished_at, + issues_count}`, `generated_at`), `api.PAGE` (Task 2). +- Produces (JS, Task 4 использует): `TOKEN` (строка), `hubFetch(path, opts) → Promise<данные>` (бросает `HubAuthError`, + `HubDownError`, `Error`), `loadHub() → Promise<"ok"|"auth"|"down">`, `showModel(model, label, title)`, + `showLogin(msg)`, `hubError(e)`, `say(html)`, `sleep(ms)`, `syncHubButtons()`, `emptyModel(text)`, переменная + `polling` (id пакета или `null`), элемент `#hubState`. + +- [ ] **Step 1: Поднять проверочный стенд** + +Создать `$WS/fake_agent.py` (текст — в разделе «Проверочный стенд»), запустить тестовый хаб и заполнить зеркало +(команды там же). Проверить: + +Run: `curl -s -H "Authorization: Bearer $CT" http://127.0.0.1:8791/board | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["issues"]), len(d["links"]), d["last_sync"]["issues_count"])'` +Expected: `4 5 4` + +- [ ] **Step 2: Написать падающую статическую проверку** + +В конец `tests/test_api.py`: + +```python +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 +``` + +Run: `uv run pytest tests/test_api.py -q -k embedded_export` +Expected: 1 failed (`assert 'DEFAULT_CSV' not in html`) + +- [ ] **Step 3: Константы, заглушки, удаление встроенной выгрузки** + +В `src/jira_hub/web/links.html`: + +1. Удалить строки `const DEFAULT_CSV = "…";` и `const DEFAULT_NAME = "…";` целиком. +2. Строку `const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле"; // служебные дорожки, всегда внизу` заменить на: + +```js +const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле", NOT_IN_HUB = "Нет в зеркале"; // служебные дорожки, всегда внизу +const STUB_TEXT = { [NOT_IN_FILE]: "нет в файле", [NOT_IN_HUB]: "нет в зеркале" }; // подпись заглушки на служебной дорожке +const TOKEN_KEY = "jira-hub-token", POLL_MS = 3000; // токен хаба в localStorage; период опроса пакета обновления, мс +``` + +3. `stubNode` и `addStubs` заменить на: + +```js +const stubNode = (key, lane) => ({ key, title: STUB_TEXT[lane], kind: "", prio: "", due: "", status: "", epic: lane, sprint: "", stub: true, who: "" }); +// связи без дублей; для «связанных» порядок концов не важен +function addLink(links, seen, src, dst, type) { + const id = type === RELATED ? [src, dst].sort().join("|") + type : src + ">" + dst + type; + if (seen.has(id)) return; seen.add(id); links.push({ src, dst, type }); +} +// задачи, упомянутые в связях, но отсутствующие в источнике, добавляются заглушками на служебную дорожку lane +function addStubs(nodes, links, lane = NOT_IN_FILE) { + const keys = new Set(nodes.map(n => n.key)), missing = new Set(); + links.forEach(l => [l.src, l.dst].forEach(k => { if (!keys.has(k)) missing.add(k); })); + missing.forEach(k => nodes.push(stubNode(k, lane))); + return missing.size; +} +``` + +(`addLink` в исходнике стоит между `stubNode` и `addStubs` — блок заменяется целиком, текст `addLink` не меняется.) + +4. В `migrateStore` строку сортировки заменить на `names.sort((a, b) => size(b) - size(a));`, а комментарий над функцией + — «…переносим на общий ключ набор того файла, где расставлено больше всего карточек». +5. В `computeVisible` строку `const tail = name => name === NO_EPIC || name === NOT_IN_FILE;` заменить на + `const tail = name => name === NO_EPIC || name === NOT_IN_FILE || name === NOT_IN_HUB;`. +6. В `emptyReason` строку `if (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач.";` заменить на + `if (!MODEL.nodes.some(n => !n.stub)) return MODEL.emptyText || (MODEL.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач.");` +7. В `renderStatus` строку `if (m.missing) notes.push(…);` заменить на + `` if (m.missing) notes.push(`упомянуты в связях, но в ${m.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${m.missing}`); `` +8. Защита пустой модели (хаб недоступен, идёт загрузка): в `fillSprints` сразу после строки `const box = $("sprint");` + +```js + if (!MODEL.nodes.length) { box.innerHTML = ""; S.sprints = new Set(); sprintDd = null; return; } +``` + + и в `fillWho` сразу после строки `const box = $("who"), { total, items } = whoItems();` + +```js + if (!MODEL.nodes.length) { box.innerHTML = ""; S.who = new Set(); whoDd = null; return; } +``` + +- [ ] **Step 4: Разметка шапки и формы входа, стили** + +1. Строку `…заменить…` (в `.top`) заменить на: + +```html + + +``` + +2. Перед `