Карточка задачи окном и комментарии из Jira (#37) #40
@ -9,7 +9,7 @@
|
||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
|
||||
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths` |
|
||||
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||
| `mirror` | зеркало задач и связей, `apply_state`, заметки, фильтры, снимок доски `board` | `db` |
|
||||
| `mirror` | зеркало задач, связей и комментариев, `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` |
|
||||
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
|
||||
| `queue` | пакеты, выдача (`claim`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `db` |
|
||||
| `backup` | копии файла базы, ротация | `paths` |
|
||||
@ -22,7 +22,9 @@
|
||||
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
|
||||
|
||||
`web/` — веб-приложение хаба (React + TypeScript + Vite): вход по клиентскому токену, страницы «Доска» (`/app/board`)
|
||||
и «Задачи» (`/app/issues`, `/app/issues/{key}`). Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
и «Задачи»
|
||||
(`/app/issues`); карточка `/app/issues/{key}` — окно поверх страницы, с которой её открыли: `shell/Layout.tsx` рисует
|
||||
страницу по адресу из `location.state.background` (`useRoutes` с явным адресом) и `issues/IssueModal.tsx` поверх неё. Собирается `make web` в `src/jira_hub/web/app/`; `api` отдаёт сборку
|
||||
без токена (`/app/assets/*` — файлы, прочие `/app…` — `index.html`, 503 без сборки), `/` и `/links` перенаправляет на
|
||||
`/app/board`. Данные — только через HTTP API с клиентским токеном; `mirror.board` отдаёт зеркало без преобразований,
|
||||
перевод в модель доски — в `web/src/board/model`. Логика доски — чистые модули `web/src/board/{model,analysis,layout}`
|
||||
|
||||
@ -158,7 +158,7 @@ curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:8790/issues?sprint=
|
||||
|
||||
## Веб-приложение
|
||||
|
||||
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}`; `/` и
|
||||
Адреса: `http://127.0.0.1:8790/app/` — доска `/app/board`, задачи `/app/issues`, карточка `/app/issues/{key}` — окно поверх списка или доски (закрытие — ✕, Esc, щелчок по фону; прямой адрес открывает окно поверх списка задач); `/` и
|
||||
`/links` перенаправляют на доску. Вход по клиентскому токену (`uv run jira-hub tokens`), токен хранится в браузере;
|
||||
`localhost` и `127.0.0.1` — разные сайты для браузера, токен и раскладка у каждого свои. Выкатка на Mac mini:
|
||||
|
||||
@ -174,6 +174,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
|
||||
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб
|
||||
:8791 с синтетическими данными) и `cd web && npm run dev` (http://127.0.0.1:5173/app/).
|
||||
|
||||
Карточка задачи: в шапке — статус Jira, статус по факту, исполнитель, срок (просроченный — красным), спринт и эпик;
|
||||
связи сгруппированы «Зависит от» / «Блокирует» / «Связана с» / «Прочие» с темой и статусом связанной задачи;
|
||||
комментарии из Jira (агент v10+) новые сверху, «Скрыть» запоминается в браузере; заметки — режим чтения, «Править»
|
||||
открывает форму. Комментарии появляются после первого обновления из Jira агентом v10.
|
||||
|
||||
## Контракт команд
|
||||
|
||||
Каждая команда описывает желаемое состояние одной задачи; агент читает, сравнивает и пишет только разницу, повтор с тем же
|
||||
|
||||
@ -53,13 +53,15 @@ test("«Обновить из Jira» с учебным агентом доход
|
||||
await expect(page.locator(".board .card[data-key]")).not.toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ⓘ открывает карточку задачи в приложении", async ({ page }) => {
|
||||
test("ⓘ открывает карточку окном поверх доски", async ({ page }) => {
|
||||
await page.locator('.board .card[data-key="AURORA-9001"]').getByRole("link", { name: "Открыть карточку AURORA-9001" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9001$/);
|
||||
await expect(page.getByRole("heading", { name: /AURORA-9001/ })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await expect(page.getByTestId("page-board")).toBeAttached();
|
||||
await expect(page.getByRole("link", { name: "Доска", exact: true })).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test("M-3: прокрутка доски после возврата из карточки задачи", async ({ page }) => {
|
||||
test("M-3: прокрутка и данные доски после окна карточки и после другого раздела", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 600 });
|
||||
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
@ -72,9 +74,33 @@ test("M-3: прокрутка доски после возврата из кар
|
||||
return a?.closest<HTMLElement>(".card")?.dataset.key ?? null;
|
||||
});
|
||||
expect(key).not.toBeNull();
|
||||
const link = page.locator(`.board .card[data-key="${key}"] a.open-card`);
|
||||
await link.click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\//);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).click();
|
||||
let boardLoads = 0;
|
||||
page.on("request", (r) => { if (new URL(r.url()).pathname === "/board") boardLoads++; });
|
||||
await page.locator(`.board .card[data-key="${key}"] a.open-card`).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page).toHaveURL(/\/app\/board$/);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
expect(boardLoads).toBe(0); // доска не перезагружалась
|
||||
// ссылки меню вверху страницы: обычный click прокрутил бы доску к ним до перехода и затёр запомненное, поэтому dispatchEvent
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).dispatchEvent("click");
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await page.getByRole("link", { name: "Доска", exact: true }).dispatchEvent("click");
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(y - 2);
|
||||
});
|
||||
|
||||
test("заметка из окна уходит в хаб и доска под ним не пересоздаётся", async ({ page }) => {
|
||||
// доска показывает вид работы по меткам Jira (fromHub), заметка work_kind в карточку доски не попадает: проверяем снимок /board
|
||||
await page.locator('.board .card[data-key="AURORA-9002"] a.open-card').click();
|
||||
const d = page.getByRole("dialog");
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Вид работы").selectOption("тестирование");
|
||||
const snap = page.waitForResponse((r) => new URL(r.url()).pathname === "/board" && r.request().method() === "GET");
|
||||
await d.getByRole("button", { name: "Сохранить" }).click();
|
||||
const body = await (await snap).json();
|
||||
expect(body.issues.find((i: { key: string }) => i.key === "AURORA-9002").work_kind).toBe("тестирование");
|
||||
await expect(d.getByRole("button", { name: "Править" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("page-board")).toBeAttached();
|
||||
await expect(page.locator('.board .card[data-key="AURORA-9002"]')).toBeVisible();
|
||||
});
|
||||
|
||||
@ -66,12 +66,13 @@ test("смена спринта и исполнителя, убирающая к
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
|
||||
test("h1 на страницах доски, задач и карточки", async ({ page }) => {
|
||||
test("h1 на страницах доски и задач, h2 в окне карточки", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.locator("h1")).toHaveText("Доска связей");
|
||||
await page.getByRole("link", { name: "Задачи" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: "Задачи" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "AURORA-9002" }).click();
|
||||
await expect(page.getByRole("heading", { level: 1, name: /AURORA-9002/ })).toBeVisible();
|
||||
// карточка — окно поверх списка: её заголовок h2 (тема задачи), h1 остаётся у страницы под окном
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { level: 2, name: "API свода" })).toBeVisible();
|
||||
await expect(page.locator("h1")).toHaveCount(1);
|
||||
});
|
||||
|
||||
@ -1,15 +1,118 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { login } from "./hub.ts";
|
||||
|
||||
test("фильтр → карточка → заметка сохраняется", async ({ page }) => {
|
||||
test("фильтр → карточка окном → заметка сохраняется и переживает перезагрузку", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await page.getByLabel("Исполнитель").selectOption("Петров Пётр Петрович");
|
||||
await expect(page).toHaveURL(/assignee=/);
|
||||
await page.getByRole("link", { name: "AURORA-9002" }).click();
|
||||
const rest = page.getByLabel("Осталось");
|
||||
await rest.fill("e2e: дописать тесты");
|
||||
await page.getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(page.getByText(/сохранено .*web/)).toBeVisible();
|
||||
await page.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Осталось").fill("e2e: дописать тесты");
|
||||
await d.getByRole("button", { name: "Сохранить" }).click();
|
||||
await expect(d.getByText("e2e: дописать тесты")).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Осталось")).toHaveValue("e2e: дописать тесты");
|
||||
await expect(page.getByRole("dialog").getByText("e2e: дописать тесты")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/app\/issues\?assignee=/);
|
||||
await expect(page.getByLabel("Исполнитель")).toHaveValue("Петров Пётр Петрович");
|
||||
});
|
||||
|
||||
test("окно поверх списка: прокрутка списка на месте при открытии и после закрытия", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 320 });
|
||||
await login(page, "/app/issues");
|
||||
await expect(page.locator("table.grid tbody tr")).toHaveCount(10);
|
||||
const y = await page.evaluate(() => { window.scrollTo(0, document.documentElement.scrollHeight); return window.scrollY; });
|
||||
expect(y).toBeGreaterThan(50);
|
||||
await page.locator("table.grid tbody tr").last().getByRole("link").click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(y);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBe(y);
|
||||
});
|
||||
|
||||
test("переход по связи и «назад»; ✕ и щелчок по фону; после закрытия «назад» не открывает карточку", async ({ page }) => {
|
||||
await login(page); // доска
|
||||
await page.getByRole("link", { name: "Задачи", exact: true }).click();
|
||||
await page.getByRole("link", { name: "AURORA-9001", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues\/AURORA-9002$/);
|
||||
await expect(d.getByRole("heading", { name: "API свода" })).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await d.getByRole("link", { name: "AURORA-9002", exact: true }).click();
|
||||
await d.getByRole("button", { name: "Закрыть" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.mouse.click(5, 300); // фон слева от окна
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/\/app\/board$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("прямой адрес и перезагрузка: окно поверх списка, ✕ ведёт в список", async ({ page }) => {
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await expect(page.getByTestId("page-issues")).toBeAttached();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("dialog").getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Закрыть" }).click();
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("карточка: описание свёрнуто, комментарии новые сверху и скрываются с запоминанием, команды свёрнуты", async ({ page }) => {
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Комментарии (3)" })).toBeVisible();
|
||||
await expect(d.getByTestId("comments").getByRole("listitem").first()).toContainText("[мост] служебный комментарий");
|
||||
await expect(d.getByRole("button", { name: "развернуть" })).toBeVisible();
|
||||
await expect(d.locator("pre.description")).toHaveClass(/clamped/); // свёрнуто построчным обрезанием (текст в DOM остаётся)
|
||||
await d.getByRole("button", { name: "развернуть" }).click();
|
||||
await expect(d.locator("pre.description")).not.toHaveClass(/clamped/);
|
||||
await expect(d.getByText("Конец описания.")).toBeVisible();
|
||||
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeHidden();
|
||||
await d.getByText("Команды моста (2)").click();
|
||||
await expect(d.getByRole("cell", { name: "ensure_fields" })).toBeVisible();
|
||||
await d.getByRole("button", { name: "Скрыть" }).click();
|
||||
await expect(d.getByText("Первый комментарий e2e")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: "Показать" })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByText("Первый комментарий e2e")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("несохранённая заметка: Esc спрашивает, «Остаться» оставляет окно, «Уйти» закрывает", async ({ page }) => {
|
||||
await login(page, "/app/issues");
|
||||
await page.getByRole("link", { name: "AURORA-9003", exact: true }).click();
|
||||
const d = page.getByRole("dialog");
|
||||
await d.getByRole("button", { name: "Править" }).click();
|
||||
await d.getByLabel("Сделано").fill("черновик");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(d.getByText("Есть несохранённые правки заметки. Уйти без сохранения?")).toBeVisible();
|
||||
await d.getByRole("button", { name: "Остаться" }).click();
|
||||
await expect(d.getByLabel("Сделано")).toHaveValue("черновик");
|
||||
await page.keyboard.press("Escape");
|
||||
await d.getByRole("button", { name: "Уйти" }).click();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/\/app\/issues$/);
|
||||
});
|
||||
|
||||
test("узкий экран: окно на всю ширину, заметки выше связей, без горизонтальной прокрутки", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 600, height: 800 });
|
||||
await login(page, "/app/issues/AURORA-9001");
|
||||
const d = page.getByRole("dialog");
|
||||
await expect(d.getByRole("heading", { name: "Схема свода" })).toBeVisible();
|
||||
expect((await d.boundingBox())!.width).toBeGreaterThan(590);
|
||||
const notesY = (await d.getByRole("heading", { name: "Служебные заметки" }).boundingBox())!.y;
|
||||
const linksY = (await d.getByRole("heading", { name: "Связи", exact: true }).boundingBox())!.y;
|
||||
expect(notesY).toBeLessThan(linksY);
|
||||
expect(await d.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
16
web/scripts/seed-extras.json
Normal file
16
web/scripts/seed-extras.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"issues": {
|
||||
"AURORA-9001": {
|
||||
"description": "Цель: схема свода для КФО.\nКонтекст: ТЗ 6.4.\nШаг 1 — таблицы.\nШаг 2 — связи.\nШаг 3 — индексы.\nШаг 4 — миграции.\nШаг 5 — проверки.\nШаг 6 — документация.\nШаг 7 — ревью.\nШаг 8 — выкатка.\nШаг 9 — приёмка.\nКонец описания.",
|
||||
"comments": [
|
||||
{"id": "501", "author": "Петров Пётр Петрович", "created": "2026-10-13T10:00:00.000+0300", "updated": "2026-10-13T10:00:00.000+0300", "body": "Первый комментарий e2e\nвторая строка"},
|
||||
{"id": "502", "author": "Иванов Иван Иванович", "created": "2026-10-14T11:30:00.000+0300", "updated": "2026-10-14T12:00:00.000+0300", "body": "Схему согласовали с аналитиком."},
|
||||
{"id": "503", "author": null, "created": "2026-10-15T09:15:00.000+0300", "updated": "2026-10-15T09:15:00.000+0300", "body": "[мост] служебный комментарий"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"commands": [
|
||||
{"type": "ensure_fields", "params": {"key": "AURORA-9001", "fields": {"duedate": "2026-10-20"}}, "status": "done"},
|
||||
{"type": "ensure_status", "params": {"key": "AURORA-9001", "status": "В работе"}, "status": "failed", "error": "нет перехода в «В работе»"}
|
||||
]
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"""Заполнить зеркало тестового хаба фикстурой доски и записать токены рядом с базой (для e2e и npm run dev).
|
||||
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет."""
|
||||
Пишет в базу напрямую (mirror.apply_state, set_note), до запуска serve: гонки с первым запросом нет.
|
||||
Дополнения только для тестового хаба (описание, комментарии, команды) — из seed-extras.json рядом со скриптом."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@ -7,15 +8,28 @@ from pathlib import Path
|
||||
from jira_hub import db, mirror, paths, settings
|
||||
|
||||
snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
|
||||
extras_path = Path(__file__).with_name("seed-extras.json")
|
||||
extras = json.loads(extras_path.read_text(encoding="utf-8")) if extras_path.exists() else {}
|
||||
extra_issues = extras.get("issues", {})
|
||||
conn = db.connect(paths.db_path())
|
||||
settings.ensure_defaults(conn)
|
||||
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} for i in snap["issues"]]
|
||||
issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]}
|
||||
| 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"]])
|
||||
for i in snap["issues"]:
|
||||
note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)}
|
||||
if note:
|
||||
mirror.set_note(conn, i["key"], note, "seed")
|
||||
if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки
|
||||
with db.transaction(conn):
|
||||
batch = conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('Тестовые команды', 'seed', ?)",
|
||||
(db.now(),)).lastrowid
|
||||
for seq, c in enumerate(extras["commands"], 1):
|
||||
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, finished_at, error) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(batch, seq, c["type"], json.dumps(c["params"], ensure_ascii=False), c["status"], db.now(),
|
||||
c.get("error")))
|
||||
(paths.home() / "tokens.json").write_text(json.dumps(settings.tokens(conn)), encoding="utf-8")
|
||||
conn.close()
|
||||
print("зеркало:", len(issues), "задач,", len(links), "связей; токены —", paths.home() / "tokens.json")
|
||||
|
||||
@ -22,8 +22,8 @@
|
||||
.issue .cols{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:24px;align-items:start}
|
||||
.issue .side{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.issue .main{min-width:0}
|
||||
@media (max-width:900px){
|
||||
.issue .cols{display:flex;flex-direction:column;gap:16px}
|
||||
@media (max-width:899.98px){
|
||||
.issue .cols{display:flex;flex-direction:column;align-items:stretch;gap:16px}
|
||||
.issue .side{display:contents}
|
||||
.issue .side .note{order:-1}
|
||||
.issue .side .other{order:1}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user