diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f4c788d..eacff13 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -101,6 +101,11 @@ страницы модуля — `/app/<модуль>/…` (`/app/jira/links`, `/app/jira/issues[/{key}]`, `/app/jira/feed`, `/app/wiki`); прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/issues/{key}`, `/app/feed` перенаправляются с сохранением `search` и `replace`. Пункты меню могут зависеть от проекта (`NavItem.when`, `visibleNav` в `shell/nav.tsx`): пока проект не определён, условный пункт скрыт; «не найдено» перечисляет только безусловные. +Экраны доски модуля jira — `web/src/modules/jira/agile/`: `model` (даты Jira, «осталось N дн.», склонение, состояние данных +`boardState`), `columns` (раскладка задач по колонкам доски, «Без колонки»), `AgileGate` (тип доски проекта, загрузка, ошибка, три +состояния данных), `BoardColumns` и страницы `BacklogPage` (`/jira/backlog`), `SprintPage` (`/jira/sprint`), `KanbanPage` +(`/jira/kanban`). Данные — один запрос `GET /agile?project=<метка>` (`modules/jira/agile.py`); страницы только смотрят. + Модуль `wiki` (`web/src/modules/wiki/index.tsx`): страница «Вики» (`/app/wiki`): дерево (`Tree`) с поиском (`Search`), страница (`WikiPage`, `PageView` — `react-markdown` + `remark-gfm`, без `rehype-raw`; ссылки `page:` ведут на страницу вики, `attachment:` скачивают файл через `download.ts`, `urlTransform.ts` пропускает только эти схемы и http(s)), форма корня без привязки (`RootForm`), diff --git a/CLAUDE.md b/CLAUDE.md index 244cdce..91af62d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ SDLC-хаб с модулями jira и wiki. Мост Jira для АРМ КА: - `uv sync` — установка окружения. - `uv run sdlc-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов. - `uv run sdlc-hub serve [--host] [--port]` — локальный запуск хаба. -- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). +- Веб-приложение — `http://127.0.0.1:8790/app/` («Обзор» — `/app/?project=<метка>`, «Связи» (доска связей) — `/app/jira/links?project=<метка>`, задачи — `/app/jira/issues?project=<метка>`, «Бэклог» — `/app/jira/backlog?project=<метка>`, «Спринт» — `/app/jira/sprint?project=<метка>`, «Kanban» — `/app/jira/kanban?project=<метка>`, лента изменений — `/app/jira/feed?project=<метка>`, вики — `/app/wiki?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на «Обзор», прежние `/app/board`, `/app/jira/board`, `/app/issues`, `/app/feed` — на страницы модуля Jira; пункты меню могут зависеть от проекта (`NavItem.when`). - `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур). - `uv run sdlc-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run sdlc-hub mcp <модуль> [--project <метка>]` — MCP модуля по stdio (модуль обязателен: `jira` — сервер `sdlc-jira`, `wiki` — сервер `sdlc-wiki`; с привязкой сессии к проекту); `uv run sdlc-hub tokens` — показать токены (клиентский и по агенту на модуль). - `uv run sdlc-hub agent publish [--module jira|wiki] PATH` — опубликовать версию агента модуля для самообновления (только вверх; по умолчанию `jira`; публикация лежит в `release/<модуль>/`). diff --git a/README.md b/README.md index a5c71b6..8cf93c5 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,15 @@ make web переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. +Экраны доски (только просмотр: ни перетаскивания, ни кнопок спринтов, ни смены статуса или ранга): «Бэклог» +`/app/jira/backlog?project=<метка>` (активные и будущие спринты, бэклог, «Прошлые спринты (N)»), «Спринт» +`/app/jira/sprint?project=<метка>[&sprint=]` (колонки доски активного спринта; устаревший `sprint` — первый активный), +«Kanban» `/app/jira/kanban?project=<метка>` (колонки доски, «Бэклог (N)» при включённом бэклоге Jira, выполненное за 14 дней). +Пункты меню зависят от типа доски проекта: у scrum-проекта «Бэклог» и «Спринт», у kanban-проекта «Kanban»; пока тип не известен, +пунктов нет. Адрес страницы чужого типа показывает «У проекта нет scrum-доски» / «У проекта нет kanban-доски» со ссылкой +«Связи». Три состояния данных: «Нужен агент v15 и обновление из Jira» (доска ещё не прочитана), «Не удалось прочитать настройки +доски из Jira» (колонок нет), сами данные. Карточка задачи открывается окном поверх экрана. + Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно (метка, название, необязательно номер доски Jira): после создания приложение переходит в новый проект и показывает ход пакета обновления. Текущий проект — параметр `project` в адресе; без него приложение подставляет последний выбранный @@ -349,6 +358,11 @@ make web ## Эксплуатация +- Выкатка спринтов и бэклога (порядок обязателен): `git pull` → `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` + (владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish + ../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках, + если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status` + покажет новую версию. - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). - Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts new file mode 100644 index 0000000..8ea5ce4 --- /dev/null +++ b/web/e2e/agile.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from "@playwright/test"; +import { login } from "./hub.ts"; + +// экраны доски: «Бэклог», «Спринт», «Kanban» (spec web-app «Спринты и бэклог»); данные — сид тестового хаба (R-27) +const side = (page: import("@playwright/test").Page) => page.getByRole("complementary", { name: "Панель приложения" }); + +test("«Бэклог» AWPCA: секции, «Прошлые спринты (1)», щелчок открывает карточку", async ({ page }) => { + await login(page, "/app/jira/backlog?project=AWPCA"); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 3" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Бэклог", exact: true, level: 2 })).toBeVisible(); + const past = page.getByText("Прошлые спринты (1)"); + await expect(past).toBeVisible(); + await past.click(); + await page.getByText("AWPCA_Спринт 1").first().click(); + const row = page.locator(".agile-row").filter({ hasText: "AURORA-9001" }).last(); + await expect(row).toBeVisible(); + await row.click(); + await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(/\/app\/jira\/backlog/); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + +test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => { + await login(page, "/app/jira/sprint?project=AWPCA"); + await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); + await expect(page.getByText("осталось 7 дн.")).toBeVisible(); + for (const name of ["К выполнению", "В работе", "Готово"]) { + await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); + } + await page.locator(".agile-card").filter({ hasText: "AURORA-9001" }).click(); + await expect(page).toHaveURL(/\/app\/jira\/issues\/AURORA-9001/); + await expect(page.getByRole("dialog")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page).toHaveURL(/\/app\/jira\/sprint/); +}); + +test("«Kanban»: «Бэклог (1)», колонки «В работе» и «Готово», подпись «за 14 дней»", async ({ page }) => { + await login(page, "/app/jira/kanban?project=KANBAN_E2E"); + await expect(page.getByText("Бэклог (1)")).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "В работе" })).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "Готово" })).toBeVisible(); + await expect(page.locator(".agile-col__name", { hasText: "Бэклог" })).toHaveCount(0); + await expect(page.getByText("за 14 дней")).toBeVisible(); + await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9102" })).toBeVisible(); + await expect(page.locator(".agile-card").filter({ hasText: "AURORA-9103" })).toBeVisible(); +}); + +test("без scrum-доски: /app/jira/backlog?project=KANBAN_E2E — «У проекта нет scrum-доски», ссылка «Связи»", async ({ page }) => { + await login(page, "/app/jira/backlog?project=KANBAN_E2E"); + await expect(page.getByText("У проекта нет scrum-доски")).toBeVisible(); + await page.getByRole("link", { name: "Связи", exact: true }).last().click(); + await expect(page).toHaveURL(/\/app\/jira\/links\?project=KANBAN_E2E/); +}); + +test("меню по проекту: у AWPCA «Бэклог» и «Спринт», у KANBAN_E2E — «Kanban»", async ({ page }) => { + await login(page, "/app/jira/links?project=AWPCA"); + await expect(side(page).getByRole("link", { name: "Бэклог" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Спринт" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Kanban" })).toHaveCount(0); + await page.goto("/app/jira/links?project=KANBAN_E2E"); + await expect(side(page).getByRole("link", { name: "Kanban" })).toBeVisible(); + await expect(side(page).getByRole("link", { name: "Бэклог" })).toHaveCount(0); + await expect(side(page).getByRole("link", { name: "Спринт" })).toHaveCount(0); +}); + +test("900 px: колонки «Спринта» прокручиваются внутри доски, страница без горизонтальной прокрутки", async ({ page }) => { + await page.setViewportSize({ width: 900, height: 900 }); + await login(page, "/app/jira/sprint?project=AWPCA"); + await expect(page.locator(".agile-col").first()).toBeVisible(); + const m = await page.evaluate(() => { + const d = document.documentElement, c = document.querySelector(".agile-columns")!; + return { doc: [d.scrollWidth, d.clientWidth], cols: [c.scrollWidth, c.clientWidth], + gutter: c.getBoundingClientRect().left - document.querySelector(".agile")!.getBoundingClientRect().left }; + }); + expect(m.gutter, "колонки не получают второй отступ внутри страницы (T10.1)").toBe(0); + expect(m.doc[0]).toBeLessThanOrEqual(m.doc[1]); + expect(m.cols[0]).toBeGreaterThan(m.cols[1]); +}); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index 56450e2..e38ae95 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -3,10 +3,11 @@ Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом.""" import json import sys +from datetime import date, timedelta from pathlib import Path from sdlc_hub.core import db, paths, settings -from sdlc_hub.modules.jira import mirror +from sdlc_hub.modules.jira import mirror, projects as jira_projects from sdlc_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) @@ -25,7 +26,35 @@ def own_labels(i): issues = [{k: v for k, v in i.items() if k not in ("work_kind", "neighbor")} | {"id": i["key"]} | {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] -mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) +# доски для экранов «Бэклог», «Спринт», «Kanban» (R-27): AWPCA — scrum 4216, KANBAN_E2E — kanban 5120 +SPRINTS = {"AWPCA_Спринт 1": 101, "AWPCA_Спринт 2": 102, "AWPCA_Спринт 3": 103} +for n, i in enumerate(issues, 1): + i["rank"] = f"0|i{n:04d}:" + sid = SPRINTS.get(i.get("sprint")) + i["sprint_ids"] = [] if sid is None else [101, 102] if i["key"] == "AURORA-9001" else [sid] + if n % 2: + i["story_points"] = float(n % 5 + 1) +today = date.today() +iso = lambda d, hhmm: f"{d.isoformat()}T{hhmm}:00.000+03:00" +now_iso = iso(today, "12:00") +issues += [{"key": k, "id": k, "summary": f"Задача канбана {k[-4:]}", "issue_type": "Задача", "status": st, "priority": "Средний", + "labels": ["KANBAN_E2E"], "rank": f"0|k{n:04d}:", "sprint_ids": [], **({"jira_updated": now_iso} if st == "Готово" else {})} + for n, (k, st) in enumerate([("AURORA-9101", "Backlog"), ("AURORA-9102", "В работе"), ("AURORA-9103", "Готово")], 1)] +jira_projects.create(conn, "KANBAN_E2E", "Канбан e2e", 5120) +boards = [ + {"id": 4216, "type": "scrum", "kanban_backlog": None, + "columns": [{"name": "К выполнению", "statuses": ["Backlog", "К выполнению", "Ожидание", "Ожидание разработки"]}, + {"name": "В работе", "statuses": ["В работе"]}, {"name": "Готово", "statuses": ["Готово"]}], + "sprints": [{"id": 101, "name": "AWPCA_Спринт 1", "state": "closed", "start_date": iso(today - timedelta(days=21), "09:00"), + "end_date": iso(today - timedelta(days=8), "09:00"), "complete_date": iso(today - timedelta(days=8), "18:00"), "goal": None}, + {"id": 102, "name": "AWPCA_Спринт 2", "state": "active", "start_date": iso(today - timedelta(days=7), "09:00"), + "end_date": iso(today + timedelta(days=7), "12:00"), "complete_date": None, "goal": "Тестовая цель спринта"}, + {"id": 103, "name": "AWPCA_Спринт 3", "state": "future", "start_date": None, "end_date": None, "complete_date": None, "goal": None}]}, + {"id": 5120, "type": "kanban", "kanban_backlog": True, + "columns": [{"name": "Бэклог", "statuses": ["Backlog"]}, {"name": "В работе", "statuses": ["В работе"]}, + {"name": "Готово", "statuses": ["Готово"]}]}, +] +mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]], boards=boards) for i in snap["issues"]: note = {k: i[k] for k in ("work_kind",) if i.get(k)} if note: diff --git a/web/src/modules/jira/agile/agile.css b/web/src/modules/jira/agile/agile.css index 90f981c..a984c47 100644 --- a/web/src/modules/jira/agile/agile.css +++ b/web/src/modules/jira/agile/agile.css @@ -36,7 +36,7 @@ .agile-row__assignee{flex-basis:100%} } /* «Спринт»: страница во всю ширину, колонки прокручиваются внутри контейнера */ -.agile--wide{padding-inline:var(--space-6);min-width:0} +.agile--wide{min-width:0} .agile-columns{display:flex;align-items:flex-start;gap:var(--space-3);overflow-x:auto;padding-bottom:var(--space-2);min-width:0} .agile-col{flex:0 0 280px;min-width:0;display:flex;flex-direction:column;gap:var(--space-2); background:var(--color-bg-subtle);border:1px solid var(--color-border-default);border-radius:var(--radius-xl);padding:var(--space-3)}