feat(api): / и /links ведут в веб-приложение, links.html удалена; документация (#21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
423cf73d3f
commit
51564854d6
@ -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`).
|
||||
|
||||
@ -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/<name>.*` + эталон `<name>.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:<n>`, подтверждение по состоянию зеркала. Остальное (API, MCP, импорт) надстраивается над ней.
|
||||
|
||||
28
README.md
28
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/).
|
||||
|
||||
## Контракт команд
|
||||
|
||||
|
||||
@ -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"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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("<!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: ключи связей из зеркала идут в разметку карточек
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<string, Box>);
|
||||
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");
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user