diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2065de9..14df35c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -13,17 +13,19 @@ | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` | | `backup` | копии файла базы, ротация | `paths` | -| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board | `queue`, `mirror`, `settings`, `backup`, `relay` | +| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick`; раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `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` отдаёт зеркало без преобразований — тот же формат будет читать -веб-приложение. +`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`) +и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку +без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на +`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований, +перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}` +и `storage.ts` (ключи `localStorage` прежней страницы), отрисовка — `web/src/board/components`. Цепочки: `api → queue → commands/mirror/settings → db`; `db → backup → paths`, `db → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. @@ -39,8 +41,9 @@ - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). - `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`. - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). +- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит. ## Открытые вопросы -- Просмотрщик на живых данных и правки из UI — отдельные change'и (`viewer-live`, `viewer-edit`). +- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. - Незакрытые SQLite-соединения и сокеты в ряде тестов (ResourceWarning при `-W error::ResourceWarning`). diff --git a/CLAUDE.md b/CLAUDE.md index 935f609..4d12660 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,10 @@ # 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-viewer-live/`). +Хаб моста Jira для АРМ КА: единственный источник истины по задачам проекта AURORA (метка AWPCA) на Mac mini (`192.168.0.106`). Хранит зеркало задач, служебные заметки и очередь команд в SQLite, отдаёт HTTP API в локальной сети (агенту на виртуалке `jira-agent` и интерфейсам) и MCP-сервер для Claude. Главный сценарий: из чата поставить пакет команд, агент на виртуалке выполняет их в Jira по правилу «проверь, потом сделай» и возвращает состояние задач в зеркало. Спеки: `openspec/specs/`; текущий change — `openspec/changes/web-app/`. ## Стек и ограничения +- Веб-приложение — `web/` (React 19 + TypeScript + Vite, TanStack Query, Vitest, Playwright; Node 24, npm); сборка в `src/jira_hub/web/app/` не в git. - Python 3.13, uv, hatchling, pytest; HTTP на stdlib (`http.server`), БД — SQLite (stdlib), MCP — пакет `mcp` (закреплён `<2`, рулинг R-10). - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. @@ -22,12 +23,14 @@ - `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`, вход по клиентскому токену). +- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`, задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску. +- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp` — MCP по stdio; `uv run jira-hub tokens` — показать токены. ## Тесты - Golden-тесты: `tests/fixtures/.*` + эталон `.expected.*`. Внешние сервисы в тестах мокаются. - SQLite в памяти или во временном `JIRA_HUB_HOME` (`tmp_path`); API поднимается на порту 0 в отдельном потоке. Jira и агент в тестах не участвуют. +- Фронт: golden-эталоны доски — `web/src/board/__fixtures__/*.expected.json` (их пишет `web/scripts/golden-from-links.ts` из старой страницы); тесты рядом с кодом `*.test.ts(x)`; e2e — `web/e2e/` на тестовом хабе. ## Первая подсистема Очередь команд (`queue.py`, Task 5): пакеты, порядок `(batch.created_at, seq)`, одна команда в полёте, временные ключи `new:`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней. diff --git a/README.md b/README.md index c2e7fa3..d87d377 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j ## HTTP API -Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, +Все маршруты, кроме веб-приложения (`GET /app…`) и перенаправления старых адресов (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON, ошибки `{error, detail}` (400/401/404/409/422). | Маршрут | Токен | Назначение | @@ -112,7 +112,7 @@ curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/j | `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 без данных) | +| `GET /`, `GET /links` | — | перенаправление на `/app/board` | | `PUT /issues/{key}/note` | клиент | заметки (частично) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд | | `POST /batches/{id}/cancel` | клиент | отмена пакета | @@ -126,18 +126,22 @@ 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` — разные сайты для браузера, токен и раскладка у каждого свои. +Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и +`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере; +`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini: -- «Перечитать» — снимок зеркала заново, без Jira; «Обновить из Jira» — пакет `refresh` по базовой JQL (автор - `viewer`), страница ждёт его и перечитывает доску; в шапке — время последнего полного обновления. -- Раскладка, дорожки и фильтры хранятся в браузере. Перенос из старой страницы: там «⋯ → Экспорт JSON», здесь «файл…» - → этот JSON: применяются раскладка и фильтры, задачи остаются из хаба. -- «файл…» с CSV из фильтра Jira — снимок файла до «Перечитать» (запасной путь, если хаб недоступен). -- «⋯ → Сменить токен хаба» — после пересоздания токенов. +``` +cd ~/projects/awp-ca/jira-hub +git pull +make web +launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub +``` + +Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Раскладка доски и вход сохраняются: приложение +читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб +:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/). ## Контракт команд diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 87e6a86..e991d34 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -1,6 +1,6 @@ """HTTP API хаба (stdlib http.server, поток на запрос, соединение SQLite на запрос). Маршруты /agent/* — агентский токен, остальные — клиентский (specs/hub-api/spec.md). -Без токена — статика без данных: страница доски (GET /, GET /links) и веб-приложение (GET /app…).""" +Без токена — статика без данных: веб-приложение (GET /app…) и перенаправление старых адресов доски (GET /, GET /links).""" import hmac import json import logging @@ -22,7 +22,6 @@ from .queue import ClaimedError log = logging.getLogger(__name__) MAX_BODY = 16 * 1024 * 1024 # предел тела запроса, байт -PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос WEB_APP = Path(__file__).parent / "web" / "app" # сборка веб-приложения (make web, не в git); читается на каждый запрос ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} @@ -33,7 +32,7 @@ SOCKET_TIMEOUT = 60 # чтение запроса; ожидание class Raw(NamedTuple): - """Не-JSON ответ обработчика (страница доски, веб-приложение). headers=None — Cache-Control: no-cache.""" + """Не-JSON ответ обработчика (веб-приложение, перенаправление). headers=None — Cache-Control: no-cache.""" content_type: str data: bytes headers: dict | None = None @@ -159,10 +158,6 @@ 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_app(hub, conn, m, q, body): """Веб-приложение: файлы assets/ сборки как есть, любой другой путь /app… — index.html (маршруты на клиенте). Путь файла после разрешения обязан лежать внутри assets/, иначе 404; без сборки — 503 с подсказкой.""" @@ -179,6 +174,11 @@ def h_app(hub, conn, m, q, body): return 200, Raw("text/html; charset=utf-8", index.read_bytes()) +def h_root(hub, conn, m, q, body): + """Старые адреса доски (/, /links) — на страницу доски веб-приложения.""" + return 302, Raw("text/html; charset=utf-8", b"", {"Location": "/app/board", "Cache-Control": "no-cache"}) + + def h_note_put(hub, conn, m, q, body): fields = {k: v for k, v in body.items() if k != "updated_by"} try: @@ -286,7 +286,7 @@ def h_settings_put(hub, conn, m, q, body): ROUTES = [ - ("GET", r"^/(links)?$", h_page, None), + ("GET", r"^/(links)?$", h_root, None), ("GET", r"^/app(?:/(.*))?$", h_app, None), ("POST", r"^/agent/claim$", h_claim, "agent"), ("GET", r"^/agent/settings$", h_agent_settings, "agent"), diff --git a/src/jira_hub/web/links.html b/src/jira_hub/web/links.html deleted file mode 100644 index b6ba315..0000000 --- a/src/jira_hub/web/links.html +++ /dev/null @@ -1,1217 +0,0 @@ - - - -Спринт 2 линк - - - - -
-
-

Спринт 2 линк

- - - - - -
- - -
-
-
- - - - - - -
- -
- - - - - - - - - - передача другому исполнителю - критический путь - сосед по связям, сам под фильтры не попадает - щелчок по виду работы или типу связи скрывает их -
-
- - - -
-
- -
- - - - diff --git a/tests/test_api.py b/tests/test_api.py index cfd873d..4cf176f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -367,30 +367,3 @@ def test_board_route(hub): 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("") - 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: ключи связей из зеркала идут в разметку карточек diff --git a/tests/test_web_app.py b/tests/test_web_app.py index 1a57bbc..24c128c 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -98,3 +98,16 @@ def test_default_web_dir_is_package_build(tmp_path): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") assert h.web_dir == api.WEB_APP assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app") + + +@pytest.mark.parametrize("path", ["/", "/links"]) +def test_old_board_addresses_redirect_to_app(app_hub, path): + status, r, body = _get(app_hub, path) + assert status == 302 + assert r.getheader("Location") == "/app/board" + assert body == b"" + + +def test_links_page_is_gone(): + assert not hasattr(api, "PAGE") + assert not (api.WEB_APP.parent / "links.html").exists() diff --git a/web/e2e/compare.spec.ts b/web/e2e/compare.spec.ts deleted file mode 100644 index a1a903e..0000000 --- a/web/e2e/compare.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test, type Page } from "@playwright/test"; -import { login } from "./hub.ts"; - -type Box = { left: string; top: string; width: string; height: string }; -const boxes = (page: Page) => page.locator(".board .card").evaluateAll((els) => - Object.fromEntries(els.map((e) => { - const el = e as HTMLElement; - return [el.querySelector("a.key")?.textContent ?? "", { left: el.style.left, top: el.style.top, width: el.style.width, height: el.style.height }]; - })) as Record); -const lanes = (page: Page) => page.locator(".board .lane-name").allTextContents(); - -test("раскладка новой доски совпадает со старой страницей", async ({ page }) => { - await login(page); // токен в localStorage общий для /app и /links - for (const mode of ["обычная", "по датам"]) { - if (mode === "по датам") await page.getByRole("button", { name: "▥ по датам" }).click(); - await expect(page.locator(".board .card").first()).toBeVisible(); - const fresh = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("svg.edges path").count() }; - await page.screenshot({ path: `test-results/compare/new-${mode}.png`, fullPage: true }); - await page.goto("/links"); - await expect(page.locator(".board .card").first()).toBeVisible(); - const old = { boxes: await boxes(page), lanes: await lanes(page), edges: await page.locator("#edges path").count() }; - await page.screenshot({ path: `test-results/compare/old-${mode}.png`, fullPage: true }); - expect(fresh.lanes).toEqual(old.lanes); - expect(fresh.edges).toBe(old.edges); - expect(Object.keys(fresh.boxes).sort()).toEqual(Object.keys(old.boxes).sort()); - for (const k of Object.keys(old.boxes)) { - for (const p of ["left", "top", "width", "height"] as const) { - expect(Math.abs(parseFloat(fresh.boxes[k][p]) - parseFloat(old.boxes[k][p])), `${mode} ${k}.${p}`).toBeLessThanOrEqual(1); - } - } - await page.goto("/app/board"); - } -});