64 KiB
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; основной checkoutjira-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 <noreply@anthropic.com>. - Решения исполнителя без обсуждения — в
openspec/changes/viewer-live/design.md, раздел## Decisions made without discussion, со следующим свободным маркером (занято R-1…R-13). - Клиентский токен живого хаба не печатать и не вводить в браузер в ходе проверок до merge: проверки идут на тестовом хабе и на копии базы с пересозданными токенами.
Review Focus
- Токен вставлен с пробелом/переводом строки или с не-ASCII символом (кириллица из буфера):
fetchс таким заголовком бросаетTypeError, страница не должна сообщать «хаб не отвечает» — поле токена показывает «недопустимые символы». Проверка — шаг Playwright в Task 3. - Хаб остановлен во время опроса пакета: страница не падает и не бросает опрос, а пишет предупреждение и повторяет; после запуска хаба доводит пакет и перечитывает доску. Проверка — шаг Playwright в Task 4.
- Токен пересоздан, пока страница открыта (401 на «Перечитать» или посреди опроса): форма входа с пояснением, без зацикливания запросов. Проверка — шаги Playwright в Task 3 и Task 4.
- В зеркале связь неизвестного типа (
Cloners), связь с эпиком и связь на ключ вне зеркала: первые две пропускаются, третья даёт заглушку на дорожке «Нет в зеркале». Проверка — фикстура учебного агента и шаг Playwright в Task 3. - Тема задачи с HTML-разметкой и кавычками (
<b>разметка</b> & "кавычки"): показывается текстом, не разметкой. Проверка — фикстура учебного агента и шаг Playwright в Task 3.
Проверочный стенд (Task 3–5)
Тестовый хаб и учебный агент живут в рабочем каталоге плана WS=$(<repo>/.superpowers/sdd/2026-10-14-viewer-live-hub)
(git-ignored). Учебный агент — файл $WS/fake_agent.py (создаётся в Task 3, шаг 1, не коммитится):
"""Учебный агент для проверки страницы доски: берёт команды тестового хаба и отвечает загрузкой фикстуры.
Запуск (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": 'ТЕСТ доски: <b>разметка</b> & "кавычки"',
"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()
Запуск тестового хаба (фоном) и заполнение зеркала одним полным прогоном:
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:
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:
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:
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:
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"),:
("GET", r"^/board$", h_board, "client"),
- Step 8: Весь набор зелёный
Run: uv run pytest -q
Expected: 209 passed
- Step 9: Коммит
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 <noreply@anthropic.com>"
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: Скопировать страницу как есть и закоммитить отдельно
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 <noreply@anthropic.com>"
- Step 2: Написать падающий тест
В tests/test_api.py к импортам добавить import urllib.error и import urllib.request; в конец файла:
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
- 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 сейчас:
<meta charset="utf-8">
<title>Спринт 2 линк</title>
Заменить на:
<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<title>Спринт 2 линк</title>
- Step 5: Реализовать отдачу страницы
В src/jira_hub/api.py: к импортам добавить from typing import NamedTuple; после SOCKET_TIMEOUT = …:
PAGE = Path(__file__).parent / "web" / "links.html" # страница доски связей; читается на каждый запрос
class Raw(NamedTuple):
"""Не-JSON ответ обработчика (страница доски)."""
content_type: str
data: bytes
После h_board:
def h_page(hub, conn, m, q, body):
return 200, Raw("text/html; charset=utf-8", PAGE.read_bytes())
В ROUTES первой строкой (область None — без токена):
("GET", r"^/(links)?$", h_page, None),
Docstring модуля дополнить: Страница доски (GET /, GET /links) — без токена: статический файл без данных.
В Handler._dispatch заменить self._auth(scope) на:
if scope is not None:
self._auth(scope)
Handler._send заменить на:
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: Коммит
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 <noreply@anthropic.com>"
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:
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:
- Удалить строки
const DEFAULT_CSV = "…";иconst DEFAULT_NAME = "…";целиком. - Строку
const NO_EPIC = "Без эпика", NOT_IN_FILE = "Нет в файле"; // служебные дорожки, всегда внизузаменить на:
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; период опроса пакета обновления, мс
stubNodeиaddStubsзаменить на:
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 не меняется.)
- В
migrateStoreстроку сортировки заменить наnames.sort((a, b) => size(b) - size(a));, а комментарий над функцией — «…переносим на общий ключ набор того файла, где расставлено больше всего карточек». - В
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;. - В
emptyReasonстрокуif (!MODEL.nodes.some(n => !n.stub)) return "В файле нет задач.";заменить наif (!MODEL.nodes.some(n => !n.stub)) return MODEL.emptyText || (MODEL.stubLane === NOT_IN_HUB ? "В зеркале хаба нет задач." : "В файле нет задач."); - В
renderStatusстрокуif (m.missing) notes.push(…);заменить наif (m.missing) notes.push(`упомянуты в связях, но в ${m.stubLane === NOT_IN_HUB ? "зеркале хаба" : "файле"} отсутствуют: ${m.missing}`); - Защита пустой модели (хаб недоступен, идёт загрузка): в
fillSprintsсразу после строкиconst box = $("sprint");
if (!MODEL.nodes.length) { box.innerHTML = ""; S.sprints = new Set(); sprintDd = null; return; }
и в fillWho сразу после строки const box = $("who"), { total, items } = whoItems();
if (!MODEL.nodes.length) { box.innerHTML = ""; S.who = new Set(); whoDd = null; return; }
- Step 4: Разметка шапки и формы входа, стили
- Строку
<span class="src">…заменить…</span>(в.top) заменить на:
<span class="src"><span class="file-name" id="fileName"></span><button type="button" class="link" id="hubReload" hidden title="Загрузить снимок зеркала хаба заново, без обращения к Jira">Перечитать</button><label class="link" for="file" title="CSV из фильтра Jira или JSON, выгруженный с этой страницы. JSON при работе с хабом переносит только расстановку карточек, дорожки и фильтры; CSV показывает снимок файла до «Перечитать»">файл…</label><input type="file" id="file" accept=".csv,.json,text/csv,application/json" hidden></span>
<span class="stats" id="hubState"></span>
- Перед
<div class="export" id="exportBox" hidden>вставить:
<div class="login" id="login" hidden>
<h2>Вход в хаб</h2>
<p>Доске нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
<p class="err" id="loginErr" hidden></p>
<form id="loginForm"><input type="password" id="tokenInput" autocomplete="off" spellcheck="false" aria-label="Клиентский токен хаба" placeholder="клиентский токен"><button class="btn" type="submit">Войти</button></form>
<p>Без хаба: <label class="link" for="file">загрузить файл CSV или JSON…</label></p>
</div>
- После строки
.empty{…}в стилях:
.login{max-width:560px;margin:48px auto;padding:20px 24px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
.login h2{margin:0 0 8px;font-size:17px;font-weight:600}
.login p{margin:0 0 12px;font-size:14px;color:var(--muted)}
.login p.err{color:var(--p-crit)}
.login code{font-family:var(--font-mono);font-size:13px;color:var(--fg)}
.login form{display:flex;gap:8px;margin:0 0 12px}
.login input{flex:1;min-width:0;font:inherit;font-size:14px;height:34px;box-sizing:border-box;padding:0 10px;color:var(--fg);background:var(--bg);border:1px solid var(--line);border-radius:6px}
.login input:focus-visible{border-color:var(--accent);outline:none}
- Step 5: Модуль хаба в скрипте и запуск
- Сразу после функции
fromJson(конец раздела 3) вставить:
// снимок хаба (GET /board) → модель доски. Эпики («Фича»/Epic) — имена дорожек, не карточки; соседи по связям — обычные карточки;
// вид работы — первая метка kind-*, иначе тип задачи; срок — плановая дата окончания, иначе срок исполнения; связи — только известных
// типов (LINK_TYPES) и не с эпиками; ключи из связей, которых нет в зеркале, — заглушки на дорожке «Нет в зеркале»
function fromHub(snap) {
const isEpic = it => /^(Фича|Epic|Эпик)$/i.test(it.issue_type || "");
const epicNames = { ...EPICS }, epics = new Set();
snap.issues.forEach(it => { if (isEpic(it)) { epics.add(it.key); epicNames[it.key] = it.summary || it.key; } });
const nodes = [], links = [], seen = new Set(), sprints = {};
snap.issues.forEach(it => {
if (isEpic(it) || !KEY_RE.test(it.key)) return;
const label = (it.labels || []).find(l => KIND_LABELS[l]), sprint = it.sprint || "";
if (sprint) sprints[sprint] = (sprints[sprint] || 0) + 1;
nodes.push({ key: it.key, title: it.summary || "", kind: label ? KIND_LABELS[label] : (it.issue_type || ""), prio: it.priority || "",
due: sortableDate(it.planned_end || it.duedate || ""), status: it.status || "",
epic: it.epic_key ? (epicNames[it.epic_key] || it.epic_key) : NO_EPIC, sprint, who: it.assignee || "", stub: false });
});
snap.links.forEach(l => {
const type = LINK_TYPES[l.link_type];
if (type && !epics.has(l.source) && !epics.has(l.target)) addLink(links, seen, l.source, l.target, type);
});
const missing = addStubs(nodes, links, NOT_IN_HUB);
const problems = snap.last_sync ? [] : ["в хабе ещё не было полного обновления из Jira — нажмите «Обновить из Jira»"];
return { nodes, links, sprints, fileName: "Хаб", problems, hasSprint: true, missing, stubLane: NOT_IN_HUB };
}
- В разделе 7 перед
function loadCsvвставить:
// --- хаб: вход по токену, снимок доски. Токен — в localStorage (TOKEN_KEY) и в памяти (на случай закрытого хранилища);
// все запросы — к тому же хосту, с заголовком Authorization
let TOKEN = store.get(TOKEN_KEY, "");
let polling = null; // id пакета обновления, ход которого показываем (Task 4)
class HubAuthError extends Error {}
class HubDownError extends Error {}
const say = html => { $("hubState").innerHTML = html; };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fmtStamp = iso => { const d = new Date(iso); return isNaN(d) ? String(iso) : d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }).replace(",", ""); };
const emptyModel = text => ({ nodes: [], links: [], sprints: {}, fileName: "", problems: [], hasSprint: false, missing: 0, emptyText: text });
async function hubFetch(path, opts = {}) {
if (!TOKEN) throw new HubAuthError("нет токена");
const headers = { "Authorization": "Bearer " + TOKEN };
if (opts.body) headers["Content-Type"] = "application/json; charset=utf-8";
let resp;
try { resp = await fetch(path, { ...opts, headers, cache: "no-store" }); }
catch (e) { throw new HubDownError("хаб не отвечает"); }
if (resp.status === 401) throw new HubAuthError("токен не подошёл");
let data = null; try { data = await resp.json(); } catch (e) {}
if (!resp.ok) throw new Error(`хаб ответил ${resp.status}${data && data.error ? ": " + data.error + (data.detail ? " — " + data.detail : "") : ""}`);
return data;
}
function syncHubButtons() {
$("hubReload").hidden = !TOKEN;
const r = $("hubRefresh"); if (r) { r.hidden = !TOKEN; r.disabled = polling !== null; }
}
function showLogin(msg) {
$("login").hidden = false; $("stage").hidden = true; $("datesBar").hidden = true; hscrollEl.hidden = true;
$("loginErr").hidden = !msg; $("loginErr").textContent = msg || "";
$("tokenInput").value = ""; $("tokenInput").focus(); syncHubButtons();
}
function hubError(e) {
if (e instanceof HubAuthError) return showLogin(TOKEN ? "Токен не подошёл: возможно, его пересоздали. Введите действующий клиентский токен." : "");
say(`<span class="warn">${esc(e.message)}</span>`);
}
function showModel(model, label, title) {
MODEL = model; $("login").hidden = true; $("stage").hidden = false;
$("fileName").textContent = label; $("fileName").title = title || label;
fillSprints(); fillWho(); refresh(); syncHubButtons();
}
// загрузить снимок хаба: "ok" — доска построена; "auth" — показана форма входа; "down" — хаб не ответил (доска прежняя, если была)
async function loadHub() {
let snap;
try { snap = await hubFetch("/board"); }
catch (e) {
if (e instanceof HubAuthError) { hubError(e); return "auth"; }
if (MODEL && MODEL.nodes.length) say(`<span class="warn">${esc(e.message)} — на доске прежние данные</span>`);
else showModel(emptyModel(`Хаб не отвечает (${e.message}). Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira.`), "Хаб недоступен");
return "down";
}
const ls = snap.last_sync;
showModel(fromHub(snap), ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было",
ls ? `Последнее полное обновление из Jira: ${fmtStamp(ls.started_at)} – ${fmtStamp(ls.finished_at)}, задач: ${ls.issues_count}. Снимок хаба: ${fmtStamp(snap.generated_at)}` : "");
return "ok";
}
$("loginForm").addEventListener("submit", ev => {
ev.preventDefault();
const t = $("tokenInput").value.trim(); if (!t) return;
if (!/^[\x21-\x7e]+$/.test(t)) { $("loginErr").hidden = false; $("loginErr").textContent = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; return; }
TOKEN = t; store.set(TOKEN_KEY, t); say(""); loadHub();
});
$("hubReload").addEventListener("click", () => { say(""); loadHub(); });
loadCsvзаменить на:
function loadCsv(text, fileName) {
showModel(fromJiraCsv(text, fileName), `${fileName} (из файла)`, `${fileName} — снимок из файла; «Перечитать» вернёт данные хаба`);
}
- В
loadJsonпоследние две строки ($("fileName").textContent = …(из JSON)…иfillSprints(); fillWho(); refresh();) заменить на:
showModel(MODEL, `${MODEL.fileName} (из файла)`, `${MODEL.fileName} — загружено из ${fileName}`);
- Комментарий
// запуск: настройки из хранилища, затем встроенный примерзаменить на// запуск: настройки из хранилища, затем данные хаба (без токена — форма входа), а последнюю строкуloadCsv(DEFAULT_CSV, DEFAULT_NAME);— на:
MODEL = emptyModel("Загрузка данных хаба…");
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");
- Step 6: Статическая проверка и синтаксис
Run: uv run pytest tests/test_api.py -q -k "embedded_export or page_served"
Expected: 2 passed
Run: python3 -c "import re,sys; h=open('src/jira_hub/web/links.html',encoding='utf-8').read(); open(sys.argv[1],'w',encoding='utf-8').write(re.search(r'<script>(.*)</script>', h, re.S).group(1))" $WS/page.js && node --check $WS/page.js && echo OK
Expected: OK
- Step 7: Перезапустить тестовый хаб и пройти сценарий в Playwright
Тестовый хаб перезапустить (страница читается с диска на каждый запрос, но перезапуск исключает старый процесс).
В Playwright (browser_navigate http://127.0.0.1:8791/), каждый пункт — снимок/скриншот и сверка:
- Первый вход: видна форма «Вход в хаб» с подсказкой
uv run jira-hub tokens, доски нет. - Ввести
токен с пробелом→ сообщение «недопустимые символы», запросов к/boardнет (browser_network_requests). - Ввести
неверныйТокен(кириллица) → то же сообщение; ввестиwrong-token→ форма с «Токен не подошёл…». - Ввести
$CT→ доска: дорожка «Справочники ГК / ГСК / ГСЗ / ЮЛ» с AURORA-1620 и AURORA-1621; AURORA-1500 на дорожке «Без эпика» видна при выборе «Все спринты» или «+ соседи по связям»; заглушка AURORA-9999 «нет в зеркале» на дорожке «Нет в зеркале» внизу; эпика AURORA-1351 карточкой нет; стрелок: 1620→1621 «блокирует начало», 1621—1500 «связанные», 1620→9999; связиClonersи 1621—1351 нет (таблица связей в меню «⋯»: 3 строки). - Тема AURORA-1620 показана текстом
ТЕСТ доски: <b>разметка</b> & "кавычки"(не жирным). - Срок AURORA-1620 —
до 21.10(плановая дата, а не срок исполнения 25.10); вид работы — «разработка», у AURORA-1621 — «UI». - Шапка: «Хаб · данные на <дата время>», в подсказке — число задач 4. Строка предупреждений: «упомянуты в связях, но в зеркале хаба отсутствуют: 1».
localStorage["jira-hub-token"]равен$CT(browser_evaluate); перезагрузка страницы — доска без формы входа.- «Перечитать» → после
fake_agent.py --onceс новымPOST /refresh(curl) тема AURORA-1621 — «(прогон 2)». - Подменить токен:
browser_evaluate localStorage.setItem("jira-hub-token","stale"), перезагрузить → форма «Токен не подошёл…»; ввести$CT→ доска. - Остановить тестовый хаб, «Перечитать» → в шапке «хаб не отвечает — на доске прежние данные», доска на месте.
Пустую доску без хаба проверить на
file://<worktree>/src/jira_hub/web/links.html(уfile://своё хранилище): без токена — форма входа;browser_evaluate localStorage.setItem("jira-hub-token","x")и перезагрузка — пустая доска «Хаб не отвечает (хаб не отвечает). Запустите хаб…», «Хаб недоступен» в шапке. Запустить тестовый хаб снова. - Загрузить CSV (любая выгрузка фильтра Jira; при отсутствии —
$WS/sample.csvиз двух строк с колонкамиКлюч проблемы,Тема,Статус,Спринт) → в шапке «<имя> (из файла)»; «Перечитать» → снова «Хаб · данные на …». - Скриншот доски в светлой и тёмной теме — без съехавших элементов в шапке.
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
- Step 8: Коммит
git add src/jira_hub/web/links.html tests/test_api.py
git commit -m "feat(web): доска на данных хаба — вход по токену, GET /board, «Перечитать» (#17)
Встроенная выгрузка от 11.10.2026 удалена; CSV и JSON — загрузка файла.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 4: страница — «Обновить из Jira», перенос раскладки, смена токена
Files:
- Modify:
src/jira_hub/web/links.html - Test:
tests/test_api.py(статическая проверка); Playwright-сценарий на тестовом хабе с учебным агентом
Interfaces:
-
Consumes (Task 3):
TOKEN,hubFetch,loadHub() → "ok"|"auth"|"down",showModel,showLogin,hubError,say,sleep,syncHubButtons,polling,POLL_MS,fromJson(text, fileName) → {model, layout, …}; хаб:GET /agent → {online},POST /refresh → {id},GET /batches/{id} → {status, remaining_seconds, commands[].{status, error}},GET /batches?limit=20 → {batches[].{id, author, status}}; статусы пакетаqueued|running|done|cancelled. -
Produces: —
-
Step 1: Написать падающую статическую проверку
В tests/test_api.py в test_page_reads_hub_and_has_no_embedded_export добавить строку:
assert 'author: "viewer"' in html and "/batches/" in html
Run: uv run pytest tests/test_api.py -q -k embedded_export
Expected: 1 failed ("/batches/" in html)
- Step 2: Разметка: кнопка и пункт меню
- В
.srcпосле кнопкиhubReloadвставить:
<button type="button" class="link" id="hubRefresh" hidden title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
- В меню «⋯» перед
<hr>:
<button type="button" id="logout">Сменить токен хаба</button>
- Стиль недоступной кнопки-ссылки — после
.link:hover,…{…}:
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
- Step 3: Обновление из Jira, возобновление опроса, импорт JSON, смена токена
- После обработчика
$("hubReload")…вставить:
// «Обновить из Jira»: пакет из одного refresh по базовой JQL от имени viewer; ход пакета — опросом раз в POLL_MS, затем снимок заново.
// Агент офлайн — пакет всё равно ставится и выполнится, когда агент выйдет на связь
async function refreshFromJira() {
if (polling !== null) return;
$("hubRefresh").disabled = true;
try {
const agent = await hubFetch("/agent");
const b = await hubFetch("/refresh", { method: "POST", body: JSON.stringify({ full: true, author: "viewer" }) });
watchBatch(b.id, agent && agent.online ? "" : "агент офлайн — пакет выполнится, когда он выйдет на связь; ");
} catch (e) { hubError(e); syncHubButtons(); }
}
async function watchBatch(id, warn = "") {
polling = id; syncHubButtons();
for (;;) {
let b;
try { b = await hubFetch(`/batches/${id}`); }
catch (e) {
if (e instanceof HubAuthError) { polling = null; hubError(e); return; }
say(`<span class="warn">пакет #${id}: ${esc(e.message)}, повтор через ${POLL_MS / 1000} с</span>`); await sleep(POLL_MS); continue;
}
if (b.status === "done" || b.status === "cancelled") {
polling = null;
const cmd = (b.commands || [])[0] || {};
const verdict = cmd.status === "failed" ? `<span class="warn">пакет #${id} не выполнен: ${esc(cmd.error || "ошибка агента")}</span>`
: b.status === "cancelled" || cmd.status === "cancelled" || cmd.status === "skipped" ? `<span class="warn">пакет #${id} отменён</span>`
: `пакет #${id} выполнен`;
await loadHub(); say(verdict); syncHubButtons(); return;
}
say(`${warn}пакет #${id} ${b.status === "queued" ? "в очереди" : "выполняется"}${b.remaining_seconds ? `, осталось ~${b.remaining_seconds} с` : ""}…`);
await sleep(POLL_MS);
}
}
// после перезагрузки страницы: незавершённый пакет доски показываем дальше, вторую загрузку не ставим
async function resumeWatch() {
try {
const list = await hubFetch("/batches?limit=20");
const b = (list.batches || []).find(x => x.author === "viewer" && (x.status === "queued" || x.status === "running"));
if (b && polling === null) watchBatch(b.id);
} catch (e) {}
}
$("hubRefresh").addEventListener("click", refreshFromJira);
$("logout").addEventListener("click", () => { TOKEN = ""; store.del(TOKEN_KEY); moreDd.close(); say(""); showLogin(""); });
loadJsonразделить: тело отif (Object.keys(r.layout).length)доdrawLegend();включительно перенести без изменений в новую функциюapplyJsonSettings(r), аloadJsonзаменить на:
// импорт JSON: раскладка и фильтры кладутся в хранилище доски. С хабом задачи и связи берутся из хаба, а не из файла;
// хаб не ответил — показываем снимок из файла
function loadJson(text, fileName) {
const r = fromJson(text, fileName);
applyJsonSettings(r);
const showFile = () => showModel(r.model, `${r.model.fileName} (из файла)`, `${r.model.fileName} — загружено из ${fileName}`);
if (!TOKEN) return showFile();
loadHub().then(st => { if (st === "down") showFile(); else if (st === "ok") say(`раскладка и фильтры из ${esc(fileName)} применены к данным хаба`); });
}
(MODEL = r.model из старого loadJson не переносится: модель ставит showModel.)
- Запуск: строку
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");заменить на:
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub().then(st => { if (st === "ok") resumeWatch(); }); } else showLogin("");
- В обработчике
loginFormвызовloadHub();заменить наloadHub().then(st => { if (st === "ok") resumeWatch(); });.
- Step 4: Статическая проверка и синтаксис
Run: uv run pytest tests/test_api.py -q -k "embedded_export or page_served"
Expected: 2 passed
Run: (извлечение скрипта и node --check — как в Task 3, шаг 6)
Expected: OK
- Step 5: Playwright-сценарий с учебным агентом
Учебный агент — фоном: uv run python $WS/fake_agent.py http://127.0.0.1:8791 (без --once). Страница
http://127.0.0.1:8791/, токен уже в localStorage.
- «Обновить из Jira» → кнопка недоступна, в шапке «пакет #N выполняется…» (агент держит 4 с); затем «пакет #N
выполнен», «данные на» новое время, тема AURORA-1621 — следующий «(прогон k)»; кнопка снова доступна.
В хабе:
curl …/batches/N—author = "viewer", одна командаrefreshсparams.jql= базовая JQL. - Агент остановлен: «Обновить из Jira» → «агент офлайн — пакет выполнится…; пакет #N в очереди…». Перезагрузить
страницу → опрос возобновился сам (кнопка недоступна, «пакет #N в очереди…»). Запустить агента → «выполнен».
Примечание: «офлайн» хаб считает по
last_seen_atстаршеONLINE_MINUTES; если агент только что был на связи — на шаге 2 «агент офлайн» не появится; тогда проверить текст, выставив в базе тестового хабаUPDATE agent_status SET last_seen_at = '2026-01-01T00:00:00+00:00'(sqlite3 $JIRA_HUB_HOME/hub.sqlite). - Агент с
--fail: «Обновить из Jira» → «пакет #N не выполнен: учебная ошибка: Jira ответила 503»; доска на месте. - Хаб остановлен посреди опроса (агент держит команду 4 с — остановить хаб в это время): в шапке «пакет #N: хаб не отвечает, повтор через 3 с»; запустить хаб → опрос продолжается; агент доведёт или хаб довыдаст команду — пакет завершается, доска перечитана.
- Токен пересоздан посреди опроса: во время «выполняется» —
browser_evaluate localStorage.setItem(...)не влияет (токен в памяти); вместо этого в базе тестового хабаUPDATE setting SET value = json_set(value, '$.client', 'rotated') WHERE key = 'tokens'→ следующий опрос → форма «Токен не подошёл…»; повторных запросов после этого нет (browser_network_requests). Ввестиrotated→ доска, опрос пакета возобновился, если пакет ещё идёт. - Перенос раскладки: в старой странице (
file:///Users/andrey.epifancev/projects/awp-ca/admin/sprint-2-links.html) передвинуть AURORA-1620 и AURORA-1621 (если их нет во встроенной выгрузке — любые две карточки, затем в JSON заменить их ключи на 1620/1621), свернуть дорожку, выбрать спринт; «⋯ → Экспорт JSON» (текст из поля — в$WS/layout.json). В доске хаба загрузить$WS/layout.json→ в шапке «Хаб · данные на …» (не «из файла»), «раскладка и фильтры из layout.json применены к данным хаба»; карточки стоят по координатам JSON, дорожка свёрнута, спринт выбран; задачи — из хаба (тема 1621 — «(прогон k)»). - Тот же JSON при остановленном хабе (страница уже открыта) → снимок из файла «… (из файла)»; хаб запустить, «Перечитать» → данные хаба.
- «⋯ → Сменить токен хаба» → форма входа без сообщения об ошибке,
localStorage["jira-hub-token"]удалён. - Скриншоты: шапка во время опроса (светлая и тёмная тема), узкое окно 900 px — кнопки шапки переносятся, не наезжают.
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
- Step 6: Коммит
git add src/jira_hub/web/links.html tests/test_api.py
git commit -m "feat(web): «Обновить из Jira», перенос раскладки импортом JSON, смена токена (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
Task 5: документы и проверка на копии живой базы
Files:
- Modify:
README.md(раздел «HTTP API», новый раздел «Доска связей»),ARCHITECTURE.md(модульapi, файлweb/links.html),CLAUDE.md(строка в «Команды») - Test: весь набор pytest; Playwright на копии живой базы
Interfaces:
-
Consumes: всё из Task 1–4.
-
Produces: —
-
Step 1: README
В разделе «HTTP API» первую строку заменить на:
Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
ошибки `{error, detail}` (400/401/404/409/422).
В таблицу после строки GET /issues/{key}:
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
Перед разделом «Контракт команд» — новый раздел:
## Доска связей
`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 — снимок файла до «Перечитать» (запасной путь, если хаб недоступен).
- «⋯ → Сменить токен хаба» — после пересоздания токенов.
- Step 2: ARCHITECTURE и CLAUDE.md
ARCHITECTURE.md: в строке таблицы модуля api к назначению добавить ; страница доски GET /, /links (файл web/links.html, без токена) и снимок GET /board; после таблицы модулей абзац:
`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
веб-приложение.
CLAUDE.md (корень репозитория, раздел «Команды»), после строки uv run jira-hub serve …:
- Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
- Step 3: Весь набор зелёный
Run: uv run pytest -q
Expected: 211 passed
- Step 4: Проверка на копии живой базы
mkdir -p $WS/hub-copy && chmod 700 $WS/hub-copy
sqlite3 "file:$HOME/.local/share/jira-hub/hub.sqlite?mode=ro" ".backup $WS/hub-copy/hub.sqlite"
sqlite3 $WS/hub-copy/hub.sqlite "DELETE FROM setting WHERE key = 'tokens';
UPDATE setting SET value = 'null' WHERE key = 'relay_dir';
UPDATE setting SET value = '0' WHERE key = 'auto_refresh_minutes';"
JIRA_HUB_HOME=$WS/hub-copy uv run jira-hub serve --host 127.0.0.1 --port 8792 # фоном
(токены пересоздаются при старте, реле и автообновление выключены — копия не трогает общую папку и живого агента).
Токен копии — JIRA_HUB_HOME=$WS/hub-copy uv run jira-hub tokens. В Playwright http://127.0.0.1:8792/:
- Доска строится: в шапке «Хаб · данные на <время последнего полного прогона живого хаба>», число задач в статусе —
число не-эпиков зеркала; дорожки — эпики AURORA-1350…; связи
Clonersнет. - Импорт JSON, выгруженного из старой страницы (
$WS/layout.jsonиз Task 4 или свежий экспорт), применяет раскладку. - Скриншот — для PR.
- Остановить хаб копии, удалить
$WS/hub-copy.
- Step 5: Коммит
git add README.md ARCHITECTURE.md CLAUDE.md
git commit -m "docs: доска связей хаба — README, ARCHITECTURE, CLAUDE.md (#17)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
После merge (не задача плана)
Живая проверка с настоящим агентом — по Migration Plan в design.md: git pull в jira-hub, перезапуск launchd,
вход по живому токену, «Обновить из Jira» (пакет viewer в хабе, агент выполнил), импорт JSON из старой страницы.