1032 lines
64 KiB
Markdown
1032 lines
64 KiB
Markdown
# 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 <noreply@anthropic.com>`.
|
||
- Решения исполнителя без обсуждения — в `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-разметкой и кавычками (`<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, не коммитится):
|
||
|
||
```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": 'ТЕСТ доски: <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()
|
||
```
|
||
|
||
Запуск тестового хаба (фоном) и заполнение зеркала одним полным прогоном:
|
||
|
||
```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 <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: Скопировать страницу как есть и закоммитить отдельно**
|
||
|
||
```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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
- [ ] **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("<!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` сейчас:
|
||
|
||
```html
|
||
<meta charset="utf-8">
|
||
<title>Спринт 2 линк</title>
|
||
```
|
||
|
||
Заменить на:
|
||
|
||
```html
|
||
<!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 = …`:
|
||
|
||
```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 <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`:
|
||
|
||
```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. Строку `<span class="src">…заменить…</span>` (в `.top`) заменить на:
|
||
|
||
```html
|
||
<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>
|
||
```
|
||
|
||
2. Перед `<div class="export" id="exportBox" hidden>` вставить:
|
||
|
||
```html
|
||
<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>
|
||
```
|
||
|
||
3. После строки `.empty{…}` в стилях:
|
||
|
||
```css
|
||
.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: Модуль хаба в скрипте и запуск**
|
||
|
||
1. Сразу после функции `fromJson` (конец раздела 3) вставить:
|
||
|
||
```js
|
||
// снимок хаба (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 };
|
||
}
|
||
```
|
||
|
||
2. В разделе 7 перед `function loadCsv` вставить:
|
||
|
||
```js
|
||
// --- хаб: вход по токену, снимок доски. Токен — в 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(); });
|
||
```
|
||
|
||
3. `loadCsv` заменить на:
|
||
|
||
```js
|
||
function loadCsv(text, fileName) {
|
||
showModel(fromJiraCsv(text, fileName), `${fileName} (из файла)`, `${fileName} — снимок из файла; «Перечитать» вернёт данные хаба`);
|
||
}
|
||
```
|
||
|
||
4. В `loadJson` последние две строки (`$("fileName").textContent = …(из JSON)…` и `fillSprints(); fillWho(); refresh();`)
|
||
заменить на:
|
||
|
||
```js
|
||
showModel(MODEL, `${MODEL.fileName} (из файла)`, `${MODEL.fileName} — загружено из ${fileName}`);
|
||
```
|
||
|
||
5. Комментарий `// запуск: настройки из хранилища, затем встроенный пример` заменить на
|
||
`// запуск: настройки из хранилища, затем данные хаба (без токена — форма входа)`, а последнюю строку
|
||
`loadCsv(DEFAULT_CSV, DEFAULT_NAME);` — на:
|
||
|
||
```js
|
||
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/`), каждый пункт — снимок/скриншот и сверка:
|
||
|
||
1. Первый вход: видна форма «Вход в хаб» с подсказкой `uv run jira-hub tokens`, доски нет.
|
||
2. Ввести `токен с пробелом` → сообщение «недопустимые символы», запросов к `/board` нет (`browser_network_requests`).
|
||
3. Ввести `неверныйТокен` (кириллица) → то же сообщение; ввести `wrong-token` → форма с «Токен не подошёл…».
|
||
4. Ввести `$CT` → доска: дорожка «Справочники ГК / ГСК / ГСЗ / ЮЛ» с AURORA-1620 и AURORA-1621; AURORA-1500 на дорожке
|
||
«Без эпика» видна при выборе «Все спринты» или «+ соседи по связям»; заглушка AURORA-9999 «нет в зеркале» на дорожке
|
||
«Нет в зеркале» внизу; эпика AURORA-1351 карточкой нет; стрелок: 1620→1621 «блокирует начало», 1621—1500 «связанные»,
|
||
1620→9999; связи `Cloners` и 1621—1351 нет (таблица связей в меню «⋯»: 3 строки).
|
||
5. Тема AURORA-1620 показана текстом `ТЕСТ доски: <b>разметка</b> & "кавычки"` (не жирным).
|
||
6. Срок AURORA-1620 — `до 21.10` (плановая дата, а не срок исполнения 25.10); вид работы — «разработка», у AURORA-1621 — «UI».
|
||
7. Шапка: «Хаб · данные на <дата время>», в подсказке — число задач 4. Строка предупреждений: «упомянуты в связях, но в
|
||
зеркале хаба отсутствуют: 1».
|
||
8. `localStorage["jira-hub-token"]` равен `$CT` (`browser_evaluate`); перезагрузка страницы — доска без формы входа.
|
||
9. «Перечитать» → после `fake_agent.py --once` с новым `POST /refresh` (curl) тема AURORA-1621 — «(прогон 2)».
|
||
10. Подменить токен: `browser_evaluate localStorage.setItem("jira-hub-token","stale")`, перезагрузить → форма «Токен не
|
||
подошёл…»; ввести `$CT` → доска.
|
||
11. Остановить тестовый хаб, «Перечитать» → в шапке «хаб не отвечает — на доске прежние данные», доска на месте.
|
||
Пустую доску без хаба проверить на `file://<worktree>/src/jira_hub/web/links.html` (у `file://` своё хранилище):
|
||
без токена — форма входа; `browser_evaluate localStorage.setItem("jira-hub-token","x")` и перезагрузка — пустая
|
||
доска «Хаб не отвечает (хаб не отвечает). Запустите хаб…», «Хаб недоступен» в шапке. Запустить тестовый хаб снова.
|
||
12. Загрузить CSV (любая выгрузка фильтра Jira; при отсутствии — `$WS/sample.csv` из двух строк с колонками
|
||
`Ключ проблемы,Тема,Статус,Спринт`) → в шапке «<имя> (из файла)»; «Перечитать» → снова «Хаб · данные на …».
|
||
13. Скриншот доски в светлой и тёмной теме — без съехавших элементов в шапке.
|
||
|
||
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
|
||
|
||
- [ ] **Step 8: Коммит**
|
||
|
||
```bash
|
||
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` добавить строку:
|
||
|
||
```python
|
||
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: Разметка: кнопка и пункт меню**
|
||
|
||
1. В `.src` после кнопки `hubReload` вставить:
|
||
|
||
```html
|
||
<button type="button" class="link" id="hubRefresh" hidden title="Поставить в хаб пакет «обновление по базовой JQL» и дождаться его: агент перечитает задачи из Jira">Обновить из Jira</button>
|
||
```
|
||
|
||
2. В меню «⋯» перед `<hr>`:
|
||
|
||
```html
|
||
<button type="button" id="logout">Сменить токен хаба</button>
|
||
```
|
||
|
||
3. Стиль недоступной кнопки-ссылки — после `.link:hover,…{…}`:
|
||
|
||
```css
|
||
.link:disabled{color:var(--muted);cursor:default;text-decoration:none}
|
||
```
|
||
|
||
- [ ] **Step 3: Обновление из Jira, возобновление опроса, импорт JSON, смена токена**
|
||
|
||
1. После обработчика `$("hubReload")…` вставить:
|
||
|
||
```js
|
||
// «Обновить из 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(""); });
|
||
```
|
||
|
||
2. `loadJson` разделить: тело от `if (Object.keys(r.layout).length)` до `drawLegend();` включительно перенести без
|
||
изменений в новую функцию `applyJsonSettings(r)`, а `loadJson` заменить на:
|
||
|
||
```js
|
||
// импорт 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`.)
|
||
|
||
3. Запуск: строку `if (TOKEN) { showModel(MODEL, "Хаб"); loadHub(); } else showLogin("");` заменить на:
|
||
|
||
```js
|
||
if (TOKEN) { showModel(MODEL, "Хаб"); loadHub().then(st => { if (st === "ok") resumeWatch(); }); } else showLogin("");
|
||
```
|
||
|
||
4. В обработчике `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`.
|
||
|
||
1. «Обновить из Jira» → кнопка недоступна, в шапке «пакет #N выполняется…» (агент держит 4 с); затем «пакет #N
|
||
выполнен», «данные на» новое время, тема AURORA-1621 — следующий «(прогон k)»; кнопка снова доступна.
|
||
В хабе: `curl …/batches/N` — `author = "viewer"`, одна команда `refresh` с `params.jql` = базовая JQL.
|
||
2. Агент остановлен: «Обновить из 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`).
|
||
3. Агент с `--fail`: «Обновить из Jira» → «пакет #N не выполнен: учебная ошибка: Jira ответила 503»; доска на месте.
|
||
4. Хаб остановлен посреди опроса (агент держит команду 4 с — остановить хаб в это время): в шапке «пакет #N: хаб не
|
||
отвечает, повтор через 3 с»; запустить хаб → опрос продолжается; агент доведёт или хаб довыдаст команду — пакет
|
||
завершается, доска перечитана.
|
||
5. Токен пересоздан посреди опроса: во время «выполняется» — `browser_evaluate localStorage.setItem(...)` не влияет
|
||
(токен в памяти); вместо этого в базе тестового хаба
|
||
`UPDATE setting SET value = json_set(value, '$.client', 'rotated') WHERE key = 'tokens'` → следующий опрос → форма
|
||
«Токен не подошёл…»; повторных запросов после этого нет (`browser_network_requests`). Ввести `rotated` → доска,
|
||
опрос пакета возобновился, если пакет ещё идёт.
|
||
6. Перенос раскладки: в старой странице (`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)»).
|
||
7. Тот же JSON при остановленном хабе (страница уже открыта) → снимок из файла «… (из файла)»; хаб запустить,
|
||
«Перечитать» → данные хаба.
|
||
8. «⋯ → Сменить токен хаба» → форма входа без сообщения об ошибке, `localStorage["jira-hub-token"]` удалён.
|
||
9. Скриншоты: шапка во время опроса (светлая и тёмная тема), узкое окно 900 px — кнопки шапки переносятся, не
|
||
наезжают.
|
||
|
||
Отклонения — исправить в странице, повторить пункт; каждое исправление поведения — строка в ledger.
|
||
|
||
- [ ] **Step 6: Коммит**
|
||
|
||
```bash
|
||
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» первую строку заменить на:
|
||
|
||
```markdown
|
||
Все маршруты, кроме страницы доски (`GET /`, `GET /links`), требуют `Authorization: Bearer <токен>`; ответы JSON,
|
||
ошибки `{error, detail}` (400/401/404/409/422).
|
||
```
|
||
|
||
В таблицу после строки `GET /issues/{key}`:
|
||
|
||
```markdown
|
||
| `GET /board` | клиент | снимок для доски: все задачи с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
|
||
| `GET /`, `GET /links` | — | страница доски связей (HTML без данных) |
|
||
```
|
||
|
||
Перед разделом «Контракт команд» — новый раздел:
|
||
|
||
```markdown
|
||
## Доска связей
|
||
|
||
`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`; после таблицы модулей абзац:
|
||
|
||
```markdown
|
||
`src/jira_hub/web/links.html` — страница доски связей: один HTML без зависимостей, хаб отдаёт его как есть. Данные
|
||
страница берёт из `GET /board` с клиентским токеном и переводит в модель доски сама (вид работы по меткам, имена
|
||
эпиков, логические типы связей); `mirror.board` отдаёт зеркало без преобразований — тот же формат будет читать
|
||
веб-приложение.
|
||
```
|
||
|
||
`CLAUDE.md` (корень репозитория, раздел «Команды»), после строки `uv run jira-hub serve …`:
|
||
|
||
```markdown
|
||
- Доска связей — `http://127.0.0.1:8790/` (страница `src/jira_hub/web/links.html`, вход по клиентскому токену).
|
||
```
|
||
|
||
- [ ] **Step 3: Весь набор зелёный**
|
||
|
||
Run: `uv run pytest -q`
|
||
Expected: 211 passed
|
||
|
||
- [ ] **Step 4: Проверка на копии живой базы**
|
||
|
||
```bash
|
||
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/`:
|
||
|
||
1. Доска строится: в шапке «Хаб · данные на <время последнего полного прогона живого хаба>», число задач в статусе —
|
||
число не-эпиков зеркала; дорожки — эпики AURORA-1350…; связи `Cloners` нет.
|
||
2. Импорт JSON, выгруженного из старой страницы (`$WS/layout.json` из Task 4 или свежий экспорт), применяет раскладку.
|
||
3. Скриншот — для PR.
|
||
4. Остановить хаб копии, удалить `$WS/hub-copy`.
|
||
|
||
- [ ] **Step 5: Коммит**
|
||
|
||
```bash
|
||
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 из старой страницы.
|