feat(sdlc-rename): бренд SDLC Hub и ключи хранилища sdlc-hub-* с переносом

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 11:56:58 +04:00
parent 528c579aa8
commit ef9ec9f46d
25 changed files with 112 additions and 43 deletions

View File

@ -89,7 +89,7 @@ for (const theme of ["light", "dark"] as const) {
test("board-collapsed", async ({ page }) => {
test.skip(w < 640, "свёртка — только на широком экране");
await page.addInitScript(() => { localStorage.setItem("jira-hub-sidebar", "collapsed"); });
await page.addInitScript(() => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); });
await login(page);
await expect(page.locator(".board .card[data-key]").first()).toBeVisible();
await settle(page);

View File

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Хаб Jira</title>
<title>SDLC Hub</title>
<link rel="icon" href="data:,">
</head>
<body>

4
web/package-lock.json generated
View File

@ -1,10 +1,10 @@
{
"name": "jira-hub-web",
"name": "sdlc-hub-web",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jira-hub-web",
"name": "sdlc-hub-web",
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/mulish": "5.3.0",

View File

@ -1,5 +1,5 @@
{
"name": "jira-hub-web",
"name": "sdlc-hub-web",
"private": true,
"type": "module",
"scripts": {

View File

@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readMigrated } from "./legacyStorage";
describe("readMigrated", () => {
beforeEach(() => localStorage.clear());
afterEach(() => vi.restoreAllMocks());
it("переносит старый ключ jira-hub-* в sdlc-hub-*", () => {
localStorage.setItem("jira-hub-token", "abc");
expect(readMigrated("sdlc-hub-token")).toBe("abc");
expect(localStorage.getItem("sdlc-hub-token")).toBe("abc");
expect(localStorage.getItem("jira-hub-token")).toBeNull();
});
it("при наличии обоих возвращает новое и не трогает старое", () => {
localStorage.setItem("jira-hub-token", "old");
localStorage.setItem("sdlc-hub-token", "new");
expect(readMigrated("sdlc-hub-token")).toBe("new");
expect(localStorage.getItem("jira-hub-token")).toBe("old");
});
it("без обоих ключей возвращает null и ничего не пишет", () => {
expect(readMigrated("sdlc-hub-token")).toBeNull();
expect(localStorage.length).toBe(0);
});
it("исключение хранилища даёт null", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("closed"); });
expect(readMigrated("sdlc-hub-token")).toBeNull();
});
});

View File

@ -0,0 +1,18 @@
// разовый перенос ключей localStorage после переименования jira-hub -> sdlc-hub: читает новый ключ,
// а если его нет — старый "jira-hub-*", переписывая значение под новым именем. Ошибки хранилища глотаем.
const NEW = "sdlc-hub-";
const OLD = "jira-hub-";
export function readMigrated(key: string): string | null {
try {
const cur = localStorage.getItem(key);
if (cur !== null) return cur;
if (!key.startsWith(NEW)) return null;
const oldKey = OLD + key.slice(NEW.length);
const v = localStorage.getItem(oldKey);
if (v === null) return null;
localStorage.setItem(key, v);
localStorage.removeItem(oldKey);
return v;
} catch { return null; }
}

View File

@ -18,7 +18,7 @@ describe("вход", () => {
renderWithProviders(app);
expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument();
expect(screen.queryByText("страница приложения")).not.toBeInTheDocument();
expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument();
expect(screen.getByText(/uv run sdlc-hub tokens/)).toBeInTheDocument();
expect(screen.getByLabelText("Клиентский токен хаба")).toHaveAttribute("type", "password");
});
@ -28,7 +28,7 @@ describe("вход", () => {
await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByText("страница приложения")).toBeInTheDocument();
expect(localStorage.getItem("jira-hub-token")).toBe("good-token");
expect(localStorage.getItem("sdlc-hub-token")).toBe("good-token");
expect(fetch.mock.calls[0][0]).toBe("/settings");
});
@ -40,7 +40,7 @@ describe("вход", () => {
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(await screen.findByRole("alert")).toHaveTextContent(/Токен не подошёл/);
expect(input).toHaveValue("wrong");
expect(localStorage.getItem("jira-hub-token")).toBeNull();
expect(localStorage.getItem("sdlc-hub-token")).toBeNull();
});
it("кириллица или пробел — «недопустимые символы», запроса нет", async () => {

View File

@ -6,9 +6,9 @@ import { setToken, TOKEN_RE } from "./token.ts";
import { useAuth } from "./useAuth.ts";
import "./login.css";
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать.";
const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run sdlc-hub tokens: токен могли пересоздать.";
const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы.";
const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова.";
const HUB_DOWN = "Хаб не отвечает: проверьте, что sdlc-hub запущен, и попробуйте снова.";
export function LoginScreen() {
const { reason } = useAuth();
@ -40,8 +40,8 @@ export function LoginScreen() {
<div className="login">
<Panel padding="lg" className="login__panel">
<h2 className="login__title">Вход в хаб</h2>
<p className="login__text">Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run jira-hub tokens</code> в каталоге
jira-hub на этом Mac. Токен сохранится в этом браузере.</p>
<p className="login__text">Приложению нужен клиентский токен хаба. Его показывает команда <code>uv run sdlc-hub tokens</code> в каталоге
sdlc-hub на этом Mac. Токен сохранится в этом браузере.</p>
{error && <Notice tone="error">{error}</Notice>}
<form className="login__form" onSubmit={submit}>
<Field label="Клиентский токен хаба">

View File

@ -5,7 +5,7 @@ describe("token", () => {
afterEach(() => { vi.restoreAllMocks(); });
it("читает токен, сохранённый прежней доской", async () => {
localStorage.setItem("jira-hub-token", "abc");
localStorage.setItem("sdlc-hub-token", "abc");
const t = await import("./token.ts");
expect(t.getToken()).toBe("abc");
});
@ -15,10 +15,10 @@ describe("token", () => {
const fn = vi.fn();
t.subscribe(fn);
t.setToken("xyz");
expect(localStorage.getItem("jira-hub-token")).toBe("xyz");
expect(localStorage.getItem("sdlc-hub-token")).toBe("xyz");
t.clearToken("bad");
expect(t.getAuth()).toEqual({ token: "", reason: "bad" });
expect(localStorage.getItem("jira-hub-token")).toBeNull();
expect(localStorage.getItem("sdlc-hub-token")).toBeNull();
expect(fn).toHaveBeenCalledTimes(2);
});

View File

@ -1,13 +1,15 @@
// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай
// клиентский токен хаба: localStorage["sdlc-hub-token"] (со старого "sdlc-hub-token" переносится при чтении) и копия в памяти на случай
// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401)
export const TOKEN_KEY = "jira-hub-token";
import { readMigrated } from "../api/legacyStorage";
export const TOKEN_KEY = "sdlc-hub-token";
export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке
export type LoginReason = "none" | "bad";
export interface AuthSnapshot { token: string; reason: LoginReason }
const readStored = (): string => {
try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; }
return readMigrated(TOKEN_KEY) ?? "";
};
let state: AuthSnapshot = { token: readStored(), reason: "none" };

View File

@ -147,7 +147,7 @@ describe("страница доски", () => {
await userEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(legend()).toBeNull();
expect(localStorage.getItem("jira-hub-legend")).toBe("0");
expect(localStorage.getItem("sdlc-hub-legend")).toBe("0");
r.unmount();
page();
await screen.findByText(/задач из 9/);

View File

@ -47,7 +47,7 @@ export function BoardPage({ project }: { project: string }) {
return (
<div data-testid="page-board"><Notice tone="error">
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте
словари в config.example.json и выполните <code>uv run jira-hub init --config …</code> или запишите их через PUT /settings.
словари в config.example.json и выполните <code>uv run sdlc-hub init --config …</code> или запишите их через PUT /settings.
</Notice></div>
);
}

View File

@ -1,8 +1,10 @@
// свёрнутость легенды доски: "0" — свёрнута, иначе развёрнута; ошибки хранилища глотаем
const KEY = "jira-hub-legend";
import { readMigrated } from "../../../api/legacyStorage";
const KEY = "sdlc-hub-legend";
export function readLegendOpen(): boolean {
try { return localStorage.getItem(KEY) !== "0"; } catch { return true; }
return readMigrated(KEY) !== "0";
}
export function saveLegendOpen(open: boolean): void {

View File

@ -12,7 +12,7 @@ export function statusText(s: BoardStatus): { text: string; warn: boolean } {
case "loading": return { text: "загрузка данных хаба…", warn: false };
case "hub-down": return {
text: s.fromFile ? "хаб не отвечает — на доске снимок из файла"
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»",
: s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (sdlc-hub serve) и нажмите «Перечитать»",
warn: true,
};
case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true };

View File

@ -83,7 +83,7 @@ export function useBoardSource(dicts: Dicts | null, project: string): BoardSourc
const pending = board.isPending;
const emptyHub = useMemo(() => emptyModel(pending
? "Загрузка данных хаба…"
: "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]);
: "Нет данных: хаб не отвечает. Запустите хаб (sdlc-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]);
const model = source.kind === "file" ? source.model : hubModel ?? emptyHub;
const label = source.kind === "file" ? `${source.model.fileName} (из файла)`
: board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было")

View File

@ -207,7 +207,7 @@ describe("карточка задачи", () => {
expect(items[0].querySelector(".body br")).not.toBeNull();
await userEvent.click(screen.getByRole("button", { name: "Скрыть" }));
expect(screen.queryByText("старый")).toBeNull();
expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1");
expect(localStorage.getItem("sdlc-hub-comments-hidden")).toBe("1");
cleanup();
open("/issues/AURORA-9001");
expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument();

View File

@ -10,7 +10,7 @@ export const SELECTS = [
type Name = (typeof SELECTS)[number][0];
// раскрыта ли панель «Фильтры» — на время сессии вкладки; ошибки хранилища глотаем
const KEY = "jira-hub-issue-filters";
const KEY = "sdlc-hub-issue-filters";
const readOpen = () => { try { return sessionStorage.getItem(KEY) === "1"; } catch { return false; } };
const saveOpen = (open: boolean) => { try { sessionStorage.setItem(KEY, open ? "1" : "0"); } catch { /* только в памяти */ } };

View File

@ -32,7 +32,7 @@ describe("карточка окном", () => {
beforeEach(() => { setToken("tok"); hub(); });
it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => {
sessionStorage.setItem("jira-hub-issue-filters", "1"); // панель «Фильтры» раскрыта
sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта
const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]);
render(<App router={router} />);
const table = await screen.findByRole("table");

View File

@ -27,7 +27,7 @@ const issuesCalls = (fetch: ReturnType<typeof hub>) =>
describe("список задач", () => {
beforeEach(() => { setToken("tok"); sessionStorage.clear(); });
const expanded = () => sessionStorage.setItem("jira-hub-issue-filters", "1");
const expanded = () => sessionStorage.setItem("sdlc-hub-issue-filters", "1");
it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => {
const fetch = hub(() => [row(1)]);
@ -266,11 +266,11 @@ describe("список задач", () => {
await userEvent.click(screen.getByRole("button", { name: /Фильтры/ }));
expect(screen.getByRole("button", { name: /Фильтры/ })).toHaveAttribute("aria-expanded", "true");
for (const n of ["Спринт", "Исполнитель", "Вид работы", "Статус Jira", "Метка", "Эпик"]) expect(screen.getByLabelText(n)).toBeInTheDocument();
expect(sessionStorage.getItem("jira-hub-issue-filters")).toBe("1");
expect(sessionStorage.getItem("sdlc-hub-issue-filters")).toBe("1");
});
it("панель раскрыта по sessionStorage", async () => {
sessionStorage.setItem("jira-hub-issue-filters", "1");
sessionStorage.setItem("sdlc-hub-issue-filters", "1");
hub(() => [row(1)]);
renderWithProviders(<IssuesPage project="AWPCA" />, { route: "/issues", path: "/issues" });
expect(await screen.findByLabelText("Спринт")).toBeInTheDocument();

View File

@ -1,8 +1,9 @@
import { useState } from "react";
import { readMigrated } from "../../../api/legacyStorage";
// выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны
export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden";
const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } };
export const COMMENTS_HIDDEN_KEY = "sdlc-hub-comments-hidden";
const read = (): boolean => readMigrated(COMMENTS_HIDDEN_KEY) === "1";
export function useCommentsHidden(): [boolean, (hidden: boolean) => void] {
const [hidden, setHidden] = useState(read);

View File

@ -1,4 +1,4 @@
// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (jira-hub#20):
// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (hub#20):
// одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса
// (M-4, T10.2), только пакеты с первой командой refresh (T10.6)
import { useQuery, useQueryClient } from "@tanstack/react-query";

View File

@ -149,13 +149,13 @@ describe("боковая панель", () => {
expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page");
});
it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => {
hub();
open("/app/board?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" });
expect(btn).toHaveAttribute("aria-expanded", "true");
await userEvent.click(btn);
expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed");
expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed");
expect(bar()).toHaveClass("is-collapsed");
cleanup();
open("/app/board?project=AWPCA");
@ -163,12 +163,12 @@ describe("боковая панель", () => {
expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false");
expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи");
await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" }));
expect(localStorage.getItem("jira-hub-sidebar")).toBeNull();
expect(localStorage.getItem("sdlc-hub-sidebar")).toBeNull();
expect(bar()).not.toHaveClass("is-collapsed");
});
it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => {
localStorage.setItem("jira-hub-sidebar", "collapsed");
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
const h = hub();
open("/app/issues?project=AWPCA");
const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" });
@ -180,7 +180,7 @@ describe("боковая панель", () => {
});
it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => {
localStorage.setItem("jira-hub-sidebar", "collapsed");
localStorage.setItem("sdlc-hub-sidebar", "collapsed");
hub();
open("/app/board?project=AWPCA");
const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" });
@ -201,7 +201,7 @@ describe("боковая панель на узком экране", () => {
open("/app/board?project=AWPCA");
const top = await screen.findByRole("banner", { name: "Строка приложения" });
expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false");
expect(within(top).getByText("Хаб Jira")).toBeInTheDocument();
expect(within(top).getByText("SDLC Hub")).toBeInTheDocument();
expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument();
expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull();
});

View File

@ -40,7 +40,7 @@ function NarrowShell(p: Props) {
<header className="topbar" aria-label="Строка приложения">
<button ref={menuBtn} type="button" className="ui-icon-btn" aria-label="Меню" title="Меню" aria-expanded={open}
onClick={() => setOpen(true)}><IconMenu /></button>
<span className="sidebar__brand">Хаб Jira</span>
<span className="sidebar__brand">SDLC Hub</span>
<span className="sidebar__spacer" />
{MODULES.map((m) => m.SidebarStatus && <m.SidebarStatus key={m.name} project={p.project} compact />)}
</header>
@ -71,7 +71,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void;
return (
<aside className={"sidebar" + (collapsed ? " is-collapsed" : "") + (drawer ? " is-drawer" : "")} aria-label="Панель приложения">
<div className="sidebar__head">
{!collapsed && <span className="sidebar__brand">Хаб Jira</span>}
{!collapsed && <span className="sidebar__brand">SDLC Hub</span>}
{drawer
? <IconButton label="Закрыть меню" icon={<IconClose />} onClick={drawer.close} />
: <IconButton label={collapsed ? "Развернуть панель" : "Свернуть панель"} icon={<IconSidebar />}

View File

@ -0,0 +1,13 @@
import { beforeEach, describe, expect, it } from "vitest";
import { readSidebar } from "./sidebarPref";
describe("sidebarPref", () => {
beforeEach(() => localStorage.clear());
it("подхватывает старый ключ jira-hub-sidebar и переносит его", () => {
localStorage.setItem("jira-hub-sidebar", "collapsed");
expect(readSidebar()).toBe(true);
expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed");
expect(localStorage.getItem("jira-hub-sidebar")).toBeNull();
});
});

View File

@ -1,8 +1,10 @@
// свёртка боковой панели: "collapsed" — свёрнута, без записи — развёрнута; ошибки хранилища глотаем
const KEY = "jira-hub-sidebar";
import { readMigrated } from "../api/legacyStorage";
const KEY = "sdlc-hub-sidebar";
export function readSidebar(): boolean {
try { return localStorage.getItem(KEY) === "collapsed"; } catch { return false; }
return readMigrated(KEY) === "collapsed";
}
export function saveSidebar(collapsed: boolean): void {