Спринты и бэклог в модуле jira (#108) #109

Merged
aep merged 21 commits from feat/108-sprints-backlog into main 2026-10-25 13:56:42 +03:00
6 changed files with 133 additions and 4 deletions
Showing only changes of commit fe1d5926a5 - Show all commits

View File

@ -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`),

View File

@ -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/<модуль>/`).

View File

@ -262,6 +262,15 @@ make web
переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` —
вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
Экраны доски (только просмотр: ни перетаскивания, ни кнопок спринтов, ни смены статуса или ранга): «Бэклог»
`/app/jira/backlog?project=<метка>` (активные и будущие спринты, бэклог, «Прошлые спринты (N)»), «Спринт»
`/app/jira/sprint?project=<метка>[&sprint=<id>]` (колонки доски активного спринта; устаревший `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/<дата-время>-<batch>.sqlite`
и хранит последние `backup_keep` копий (по умолчанию 30).
- Файлы вложений: `files/<sha256>`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый

81
web/e2e/agile.spec.ts Normal file
View File

@ -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<HTMLElement>(".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]);
});

View File

@ -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:

View File

@ -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)}