diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..4417154 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearToken, setToken } from "../auth/token.ts"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { api, ApiError, AuthError, checkToken, HubDownError } from "./client.ts"; + +describe("api", () => { + beforeEach(() => setToken("tok")); + + it("шлёт токен и параметры, пустые параметры выбрасывает", async () => { + const fetch = mockFetch(() => json(200, { issues: [] })); + await api.get("/issues", { sprint: "S 2", q: "", epic: null, limit: 50 }); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe("/issues?sprint=S+2&limit=50"); + expect((init!.headers as Record).Authorization).toBe("Bearer tok"); + expect(init!.cache).toBe("no-store"); + }); + + it("PUT отправляет JSON", async () => { + const fetch = mockFetch((_m, _u, body) => json(200, body)); + expect(await api.put("/issues/A-1/note", { rest_text: "x" })).toEqual({ rest_text: "x" }); + expect((fetch.mock.calls[0][1]!.headers as Record)["Content-Type"]).toBe("application/json; charset=utf-8"); + }); + + it("401 — AuthError, 5xx и сеть — HubDownError, прочие 4xx — ApiError с detail", async () => { + mockFetch(() => json(401, { error: "unauthorized" })); + await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError); + mockFetch(() => json(503, {})); + await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError); + mockFetch(() => { throw new TypeError("Failed to fetch"); }); + await expect(api.get("/board")).rejects.toBeInstanceOf(HubDownError); + mockFetch(() => json(422, { error: "bad_note", detail: "неизвестные поля заметки: x" })); + const e = await api.put("/issues/A-1/note", { x: 1 }).catch((err: unknown) => err); + expect(e).toBeInstanceOf(ApiError); + expect(e).toMatchObject({ status: 422, error: "bad_note", detail: "неизвестные поля заметки: x" }); + }); + + it("без токена — AuthError без запроса", async () => { + clearToken(); + const fetch = mockFetch(() => json(200, {})); + await expect(api.get("/board")).rejects.toBeInstanceOf(AuthError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("checkToken проверяет переданный токен, не сохранённый", async () => { + const fetch = mockFetch(() => json(200, { kind_labels: {} })); + await checkToken("candidate"); + expect((fetch.mock.calls[0][1]!.headers as Record).Authorization).toBe("Bearer candidate"); + expect(fetch.mock.calls[0][0]).toBe("/settings"); + }); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..9c796d5 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,58 @@ +// единственный клиент HTTP API хаба: тот же хост, клиентский токен в Authorization, ответы — JSON. +// Ошибки: AuthError — 401 или нет токена; HubDownError — сеть или 5xx; ApiError — прочие 4xx с {error, detail} +import { getToken } from "../auth/token.ts"; +import type { HubSettings } from "./types.ts"; + +export class AuthError extends Error {} +export class HubDownError extends Error {} +export class ApiError extends Error { + readonly status: number; + readonly error: string; + readonly detail: string | null; + constructor(status: number, error: string, detail: string | null) { + super(detail ? `${error}: ${detail}` : error); + this.status = status; + this.error = error; + this.detail = detail; + } +} + +export type Params = Record; + +function withParams(path: string, params?: Params): string { + if (!params) return path; + const q = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v !== null && v !== undefined && v !== "") q.set(k, String(v)); + const s = q.toString(); + return s ? `${path}?${s}` : path; +} + +async function call(method: string, path: string, body?: unknown, token = getToken()): Promise { + if (!token) throw new AuthError("нет токена"); + const headers: Record = { Authorization: "Bearer " + token, Accept: "application/json" }; + if (body !== undefined) headers["Content-Type"] = "application/json; charset=utf-8"; + let resp: Response; + try { + resp = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: "no-store" }); + } catch { + throw new HubDownError("хаб не отвечает"); + } + if (resp.status === 401) throw new AuthError("токен не подошёл"); + let data: unknown = null; + try { data = await resp.json(); } catch { /* тело не JSON */ } + if (resp.status >= 500) throw new HubDownError(`хаб ответил ${resp.status}`); + if (!resp.ok) { + const d = (data ?? {}) as { error?: string; detail?: unknown }; + throw new ApiError(resp.status, d.error ?? "http", d.detail === undefined || d.detail === null ? null : String(d.detail)); + } + return data as T; +} + +export const api = { + get: (path: string, params?: Params) => call("GET", withParams(path, params)), + post: (path: string, body: unknown = {}) => call("POST", path, body), + put: (path: string, body: unknown) => call("PUT", path, body), +}; + +// проверка токена до сохранения: экран входа +export const checkToken = (token: string) => call("GET", "/settings", undefined, token); diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts new file mode 100644 index 0000000..77a9ff6 --- /dev/null +++ b/web/src/api/queries.ts @@ -0,0 +1,46 @@ +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "./client.ts"; +import type { BoardSnapshot, HubSettings, IssueDetail, IssueRow, Note, NoteFields } from "./types.ts"; + +export const ISSUE_FILTER_NAMES = ["sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q"] as const; +export type IssueFilters = Partial>; +export const ISSUE_PAGE = 50; + +export const qk = { + settings: ["settings"] as const, + board: ["board"] as const, + issues: (f: IssueFilters) => ["issues", f] as const, + issue: (key: string) => ["issue", key] as const, +}; + +export const useSettings = () => + useQuery({ queryKey: qk.settings, queryFn: () => api.get("/settings"), staleTime: Infinity }); + +export const useBoard = () => useQuery({ queryKey: qk.board, queryFn: () => api.get("/board") }); + +export const useIssue = (key: string) => + useQuery({ queryKey: qk.issue(key), queryFn: () => api.get(`/issues/${encodeURIComponent(key)}`) }); + +// список порциями по ISSUE_PAGE: следующая порция есть, пока последняя полная +export const useIssues = (filters: IssueFilters) => + useInfiniteQuery({ + queryKey: qk.issues(filters), + initialPageParam: 0, + queryFn: ({ pageParam }) => + api.get<{ issues: IssueRow[] }>("/issues", { ...filters, limit: ISSUE_PAGE, offset: pageParam }).then((r) => r.issues), + getNextPageParam: (last, all) => (last.length < ISSUE_PAGE ? undefined : all.length * ISSUE_PAGE), + }); + +// сохранение заметки: key фиксируется при создании мутации — ответ пишется в карточку той задачи, для которой отправлен +export function useSaveNote(key: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (fields: NoteFields) => + api.put(`/issues/${encodeURIComponent(key)}/note`, { ...fields, updated_by: "web" }), + onSuccess: (note) => { + qc.setQueryData(qk.issue(key), (d) => (d ? { ...d, note } : d)); + void qc.invalidateQueries({ queryKey: ["issues"] }); + void qc.invalidateQueries({ queryKey: qk.board }); + }, + }); +} diff --git a/web/src/api/queryClient.ts b/web/src/api/queryClient.ts new file mode 100644 index 0000000..7462f94 --- /dev/null +++ b/web/src/api/queryClient.ts @@ -0,0 +1,13 @@ +import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query"; +import { clearToken, getToken } from "../auth/token.ts"; +import { AuthError } from "./client.ts"; + +// 401 в любом запросе или действии — экран входа с пояснением «токен не подошёл» +export function makeQueryClient(): QueryClient { + const onError = (e: unknown) => { if (e instanceof AuthError) clearToken(getToken() ? "bad" : "none"); }; + return new QueryClient({ + queryCache: new QueryCache({ onError }), + mutationCache: new MutationCache({ onError }), + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false, staleTime: 30_000 } }, + }); +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..c8ca895 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,54 @@ +// ответы HTTP API хаба (openspec/specs/hub-api); поля — как отдаёт хаб, без переименований + +export interface BoardIssue { + key: string; summary: string | null; issue_type: string | null; status: string | null; priority: string | null; + assignee: string | null; sprint: string | null; duedate: string | null; planned_start: string | null; + planned_end: string | null; epic_key: string | null; labels: string[]; neighbor: boolean; + fact_status: string | null; work_kind: string | null; +} +export interface BoardLink { source: string; target: string; link_type: string } +export interface SyncRun { started_at: string; finished_at: string; issues_count: number } +export interface BoardSnapshot { generated_at: string; last_sync: SyncRun | null; issues: BoardIssue[]; links: BoardLink[] } + +// словари настроек: kind_labels — вид работы → метка Jira, link_types — логический тип → имя типа связи Jira, +// status_labels — статус по факту → метка Jira +export interface HubSettings { + kind_labels?: Record; + link_types?: Record; + status_labels?: Record; + [key: string]: unknown; +} + +export interface IssueRow { + key: string; id: string | null; issue_type: string | null; summary: string | null; description: string | null; + status: string | null; priority: string | null; assignee: string | null; reporter: string | null; + duedate: string | null; planned_start: string | null; planned_end: string | null; epic_key: string | null; + labels: string[]; sprint: string | null; created: string | null; jira_updated: string | null; fetched_at: string; + neighbor: boolean; fact_status?: string | null; work_kind?: string | null; +} + +export const NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] as const; +export type NoteField = (typeof NOTE_FIELDS)[number]; +export type NoteFields = Partial>; +export type Note = Record & { key: string; updated_at: string; updated_by: string }; + +export interface IssueCommand { + id: number; batch_id: number; type: string; status: string; finished_at: string | null; error: string | null; +} +export interface IssueDetail extends IssueRow { + links_out: { target_key: string; link_type: string }[]; + links_in: { source_key: string; link_type: string }[]; + note: Note | null; + commands: IssueCommand[]; +} + +export type BatchStatus = "queued" | "running" | "done" | "cancelled"; +export interface BatchCommand { + id: number; seq: number; type: string; status: string; error: string | null; finished_at: string | null; +} +export interface Batch { + id: number; title: string; author: string; created_at: string; cancelled_at: string | null; status: BatchStatus; + counts: Record; remaining_seconds: number; commands?: BatchCommand[]; +} +export interface AgentView { online: boolean; last_seen_at: string | null; warning: string | null } +export interface CreatedBatch { id: number; estimate_seconds: number; count: number } diff --git a/web/src/auth/AuthGate.tsx b/web/src/auth/AuthGate.tsx new file mode 100644 index 0000000..294c80c --- /dev/null +++ b/web/src/auth/AuthGate.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; +import { LoginScreen } from "./LoginScreen.tsx"; +import { useAuth } from "./useAuth.ts"; + +// пока нет токена — только экран входа: страниц и их фоновых запросов нет (M-6) +export function AuthGate({ children }: { children: ReactNode }) { + const { token } = useAuth(); + return token ? <>{children} : ; +} diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx new file mode 100644 index 0000000..fa4b1e3 --- /dev/null +++ b/web/src/auth/LoginScreen.test.tsx @@ -0,0 +1,65 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { json, mockFetch } from "../test/fetchMock.ts"; +import { renderWithProviders } from "../test/render.tsx"; +import { AuthGate } from "./AuthGate.tsx"; +import { clearToken, getAuth } from "./token.ts"; + +const app =

страница приложения

; + +describe("вход", () => { + beforeEach(() => clearToken()); + + it("без токена — экран входа, страница не видна", () => { + renderWithProviders(app); + expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); + expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); + expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument(); + }); + + it("верный токен сохраняется и открывает приложение", async () => { + const fetch = mockFetch(() => json(200, {})); + renderWithProviders(app); + 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(fetch.mock.calls[0][0]).toBe("/settings"); + }); + + it("неверный токен не сохраняется, поле не стирается", async () => { + mockFetch(() => json(401, { error: "unauthorized" })); + renderWithProviders(app); + const input = screen.getByLabelText("Клиентский токен хаба"); + await userEvent.type(input, "wrong"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/Токен не подошёл/)).toBeInTheDocument(); + expect(input).toHaveValue("wrong"); + expect(localStorage.getItem("jira-hub-token")).toBeNull(); + }); + + it("кириллица или пробел — «недопустимые символы», запроса нет", async () => { + const fetch = mockFetch(() => json(200, {})); + renderWithProviders(app); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "ток ен"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(screen.getByText(/недопустимые символы/)).toBeInTheDocument(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("хаб не отвечает — своё сообщение, не «токен не подошёл»", async () => { + mockFetch(() => { throw new TypeError("Failed to fetch"); }); + renderWithProviders(app); + await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "tok"); + await userEvent.click(screen.getByRole("button", { name: "Войти" })); + expect(await screen.findByText(/Хаб не отвечает/)).toBeInTheDocument(); + }); + + it("после 401 в приложении экран входа поясняет причину", async () => { + clearToken("bad"); + renderWithProviders(app); + await waitFor(() => expect(getAuth().reason).toBe("bad")); + expect(screen.getByText(/Токен не подошёл/)).toBeInTheDocument(); + }); +}); diff --git a/web/src/auth/LoginScreen.tsx b/web/src/auth/LoginScreen.tsx new file mode 100644 index 0000000..fba6398 --- /dev/null +++ b/web/src/auth/LoginScreen.tsx @@ -0,0 +1,48 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState, type FormEvent } from "react"; +import { AuthError, checkToken } from "../api/client.ts"; +import { setToken, TOKEN_RE } from "./token.ts"; +import { useAuth } from "./useAuth.ts"; + +const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать."; +const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; +const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова."; + +export function LoginScreen() { + const { reason } = useAuth(); + const qc = useQueryClient(); + const [value, setValue] = useState(""); + const [error, setError] = useState(reason === "bad" ? BAD_TOKEN : ""); + const [busy, setBusy] = useState(false); + + async function submit(ev: FormEvent) { + ev.preventDefault(); + const t = value.trim(); + if (!t) return; + if (!TOKEN_RE.test(t)) { setError(BAD_CHARS); return; } + setBusy(true); + try { + await checkToken(t); + qc.clear(); + setToken(t); + } catch (e) { + setError(e instanceof AuthError ? BAD_TOKEN : HUB_DOWN); + } finally { + setBusy(false); + } + } + + return ( +
+

Вход в хаб

+

Приложению нужен клиентский токен хаба. Его показывает команда uv run jira-hub tokens в каталоге + jira-hub на этом Mac. Токен сохранится в этом браузере.

+ {error &&

{error}

} +
+ setValue(e.target.value)} /> + +
+
+ ); +} diff --git a/web/src/auth/token.test.ts b/web/src/auth/token.test.ts new file mode 100644 index 0000000..b1d9ac5 --- /dev/null +++ b/web/src/auth/token.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("token", () => { + beforeEach(() => { vi.resetModules(); localStorage.clear(); }); + afterEach(() => { vi.restoreAllMocks(); }); + + it("читает токен, сохранённый прежней доской", async () => { + localStorage.setItem("jira-hub-token", "abc"); + const t = await import("./token.ts"); + expect(t.getToken()).toBe("abc"); + }); + + it("setToken и clearToken пишут в localStorage и оповещают подписчиков", async () => { + const t = await import("./token.ts"); + const fn = vi.fn(); + t.subscribe(fn); + t.setToken("xyz"); + expect(localStorage.getItem("jira-hub-token")).toBe("xyz"); + t.clearToken("bad"); + expect(t.getAuth()).toEqual({ token: "", reason: "bad" }); + expect(localStorage.getItem("jira-hub-token")).toBeNull(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("закрытое хранилище: токен живёт в памяти, исключений нет", async () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("closed", "SecurityError"); }); + const t = await import("./token.ts"); + expect(t.getToken()).toBe(""); + t.setToken("mem"); + expect(t.getToken()).toBe("mem"); + }); + + it("TOKEN_RE пропускает только печатный ASCII без пробелов", async () => { + const { TOKEN_RE } = await import("./token.ts"); + expect(TOKEN_RE.test("Ab-_9~")).toBe(true); + expect(TOKEN_RE.test("ab c")).toBe(false); + expect(TOKEN_RE.test("токен")).toBe(false); + }); +}); diff --git a/web/src/auth/token.ts b/web/src/auth/token.ts new file mode 100644 index 0000000..bc67247 --- /dev/null +++ b/web/src/auth/token.ts @@ -0,0 +1,33 @@ +// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай +// закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401) +export const TOKEN_KEY = "jira-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 ""; } +}; + +let state: AuthSnapshot = { token: readStored(), reason: "none" }; +const listeners = new Set<() => void>(); +const emit = (next: AuthSnapshot) => { state = next; listeners.forEach((l) => l()); }; + +export const getAuth = (): AuthSnapshot => state; +export const getToken = (): string => state.token; + +export function setToken(token: string): void { + try { localStorage.setItem(TOKEN_KEY, token); } catch { /* закрытое хранилище: токен только в памяти */ } + emit({ token, reason: "none" }); +} + +export function clearToken(reason: LoginReason = "none"): void { + try { localStorage.removeItem(TOKEN_KEY); } catch { /* закрытое хранилище */ } + emit({ token: "", reason }); +} + +export function subscribe(fn: () => void): () => void { + listeners.add(fn); + return () => { listeners.delete(fn); }; +} diff --git a/web/src/auth/useAuth.ts b/web/src/auth/useAuth.ts new file mode 100644 index 0000000..620f99d --- /dev/null +++ b/web/src/auth/useAuth.ts @@ -0,0 +1,4 @@ +import { useSyncExternalStore } from "react"; +import { getAuth, subscribe, type AuthSnapshot } from "./token.ts"; + +export const useAuth = (): AuthSnapshot => useSyncExternalStore(subscribe, getAuth); diff --git a/web/src/test/fetchMock.ts b/web/src/test/fetchMock.ts new file mode 100644 index 0000000..e44d2e2 --- /dev/null +++ b/web/src/test/fetchMock.ts @@ -0,0 +1,16 @@ +import { vi } from "vitest"; + +export function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +// подмена fetch: handler получает метод, URL (относительно http://hub.test) и разобранное тело +export function mockFetch(handler: (method: string, url: URL, body: unknown) => Response | Promise) { + const fn = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => { + const url = new URL(String(input), "http://hub.test"); + const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined; + return handler(init.method ?? "GET", url, body); + }); + vi.stubGlobal("fetch", fn); + return fn; +} diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx new file mode 100644 index 0000000..7fb580b --- /dev/null +++ b/web/src/test/render.tsx @@ -0,0 +1,13 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { render } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { createMemoryRouter, RouterProvider } from "react-router"; +import { makeQueryClient } from "../api/queryClient.ts"; + +// компонент под QueryClient и data-роутером в памяти: path — шаблон маршрута (для useParams), route — открытый адрес +export function renderWithProviders(ui: ReactElement, { route = "/", path = "*" }: { route?: string; path?: string } = {}) { + const client = makeQueryClient(); + const router = createMemoryRouter([{ path, element: ui }, { path: "*", element:

другая страница

}], + { initialEntries: [route] }); + return { client, router, ...render() }; +}