diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index beeab1f..ea9be1c 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -250,9 +250,9 @@ раскладки AWPCA. Цена ошибки: схема ключей в `storage.ts`. 21. 2026-10-19 — **`last_sync` после создания проекта — `null` до завершения его обновления, `sync_run.query` не переписывается.** Обоснование: прежний прогон новую метку не покрывал. Цена ошибки: временная надпись на доске. -22. 2026-10-19 — **пустой `author` в `POST /projects` — как `api`, нестрока — 422 `bad_project`.** Обоснование: как у - остальных маршрутов (`body.get("author") or "api"`); спека требует 422 для не-строки. Цена ошибки: пустая строка - пишется автором `api`, а не отвергается. +22. 2026-10-19 — **пустая строка и отсутствие `author` в `POST /projects` — как `api`; любое другое нестроковое значение (в том числе `0`, `false`, `[]`, `{}`) — 422 `bad_project`.** Обоснование: как у + остальных маршрутов (пусто — `api`); спека требует 422 для не-строки, и `0`/`false` не должны молча становиться `api`. Цена ошибки: пустая + строка пишется автором `api`, а не отвергается. 23. 2026-10-19 — **`CommandError` из постановки refresh в `POST /projects` — 422 `bad_batch`, а не `bad_project`.** Обоснование: ошибка относится к пакету, а не к полям проекта; спека называет только `project_exists` и `bad_project`. Цена ошибки: код ошибки в ответе. @@ -264,9 +264,11 @@ 26. 2026-10-19 — **`project` в `retry` / `POST /batches` не строка — 422 `bad_retry` / `bad_batch`; в `retry_command` сначала сверка `project` с проектом исходного, затем `create_batch` с проверками проекта.** Обоснование: «команда #N не из проекта P» точнее ошибки проверок. Цена ошибки: текст 422. -27. 2026-10-19 — **типы досок из результата `refresh` пишутся только при `status = done`; результат без ключа `boards` - или с мусорными значениями типы не трогает.** Обоснование: у проваленной команды результат неполон, а агент v11 ключа - не присылает. Цена ошибки: одно условие в `report_result`. +27. 2026-10-19 — **типы досок из результата `refresh` пишутся в любом статусе отчёта (`done` и `failed`), если в + результате-словаре есть ключ `boards`; без ключа или с мусорными значениями типы не трогаются.** Обоснование: тип + доски — факт о доске, а не часть выборки: проваленная выборка задач не делает присланный тип неверным; спека + commands требует записать присланные типы по результату refresh. Поздний отчёт по уже терминальной команде типы не + пишет (ветка терминальной команды возвращается раньше). Цена ошибки: одно условие в `report_result`. 28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без обращения к зеркалу.** Обоснование: `new:` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта) единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`. @@ -299,3 +301,5 @@ показывает страницы; `findByTestId` ловил заглушку «Загрузка настроек», которую страница сменяет другим элементом с тем же `data-testid`, и проверка падала на оторванном узле. Цена ошибки: только тесты. 35. 2026-10-19 — **в тесте `storage.test.ts` выражение `{…} as JsonImport` заменено на `as unknown as JsonImport`.** Обоснование: `tsc --noEmit` (шаг `make check`) отвергает прямое приведение частичного объекта к `JsonImport` (нет `model`, `dateColumns`, `collapsedLanes`); в тесте важны только `layout`, `layoutByDate`, `laneOrder`, `filters`, и `applyJsonSettings` читает только их. Цена ошибки: тест передаёт неполный импорт; если `applyJsonSettings` начнёт читать другие поля, тест выдаст неверный результат, а не ошибку типа. +36. 2026-10-19 — **`POST /batches` с неизвестным `project`: сообщение «проекта {P} нет» без префикса «команда {i}».** + Обоснование: ошибка пакетная, а не одной команды. Цена ошибки: только текст сообщения. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index e277fb6..2292b57 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -418,8 +418,10 @@ def h_projects_get(hub, conn, m, q, body): def h_projects_post(hub, conn, m, q, body): """Новый проект: проект и пакет «Новый проект <метка>» (один refresh по базовой JQL с новой меткой) — в одной транзакции; ошибка любого шага не оставляет ни проекта, ни пакета (hub-api, «Маршруты проектов»).""" - author = body.get("author") or "api" - if not isinstance(author, str): + author = body.get("author") + if author is None or author == "": + author = "api" + elif not isinstance(author, str): raise ApiError(422, "bad_project", "author — строка") try: with db.transaction(conn): diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index 94a786f..e1343e0 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -362,8 +362,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= if cmd["type"] == "refresh": conn.execute("UPDATE sync_run SET finished_at = ?, jira_calls = ?, rate_limited = ? WHERE command_id = ?", (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) - if status == "done" and isinstance(result, dict) and "boards" in result: - projects.record_board_types(conn, result["boards"]) # без ключа (агент v11) типы не трогаем + if isinstance(result, dict) and "boards" in result: + projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем if status == "done" and is_full_refresh(conn, command_id): # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) diff --git a/tests/test_api.py b/tests/test_api.py index d945af3..3bfe694 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,6 +13,7 @@ import pytest from jira_hub import api, db, files, projects, queue, release, settings from jira_hub.client import HubClient, HubError +from jira_hub.commands import CommandError @pytest.fixture @@ -274,7 +275,8 @@ def test_settings_put_protected_key_in_batch_rejects_whole_batch(hub): @pytest.mark.parametrize("body", [ {"pacing": 5}, {"pacing": {"ensure_fields": "abc"}}, {"pacing": {"ensure_fields": -1}}, {"pacing": {"неизвестная": 1}}, {"port": "x"}, {"port": 0}, {"port": 70000}, {"backup_keep": -1}, - {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, + {"auto_refresh_minutes": 1.5}, {"min_agent_version": "2"}, {"link_types": []}, + {"labels": {}}, {"initialized": 1}, {"closed_statuses": "Готово"}, {"pacing": {"refresh": 3}, "backup_keep": -1}, # хорошее рядом с плохим не записывается ]) def test_settings_put_invalid_value_is_422(hub, body): @@ -812,6 +814,10 @@ def test_create_project_without_board(hub): ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": 0}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": False}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": []}, 422, None), + ({"label": "STOCKS_DRRK", "name": "x", "author": {}}, 422, None), ]) def test_create_project_errors(hub, body, status, field): c = hub["client"] @@ -823,6 +829,24 @@ def test_create_project_errors(hub, body, status, field): assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before +@pytest.mark.parametrize("author", [None, ""]) +def test_create_project_empty_author_is_api(hub, author): + c = hub["client"] + out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "x", "author": author}) + assert c.get(f"/batches/{out['batch_id']}")["author"] == "api" + + +def test_create_project_batch_error_rolls_back(hub, monkeypatch): # T3.2 + def boom(*a, **k): + raise CommandError("не ставится") + monkeypatch.setattr(queue, "create_batch", boom) + c = hub["client"] + with pytest.raises(HubError) as e: + c.post("/projects", {"label": "STOCKS_DRRK", "name": "x"}) + assert e.value.status == 422 and e.value.error == "bad_batch" + assert c.get("/projects")["projects"] == [] + + def test_projects_need_client_token(hub): with pytest.raises(HubError) as e: hub["agent"].get("/projects") diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 20d629c..dab86e3 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -402,6 +402,8 @@ def test_unbound_session(live, monkeypatch): # сце mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): assert out == mcp_server.NOT_BOUND assert calls == [] + for out in (mcp_server.batches(), mcp_server.sync_log(), mcp_server.settings_get(), asyncio.run(mcp_server.batch_wait(1))): + assert out != mcp_server.NOT_BOUND # не зависящие от проекта — работают без привязки assert "AWPCA" in mcp_server.projects() assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки diff --git a/tests/test_queue.py b/tests/test_queue.py index b28c79e..a61b1f3 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -929,9 +929,9 @@ def test_refresh_result_board_type_changes_scrum_to_kanban(conn, add_project): assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" -def test_refresh_failed_result_keeps_board_types(conn, add_project): +def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 / T6.1: тип доски — факт о доске, а не часть выборки add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) c = queue.claim(conn) queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e") - assert projects.get(conn, "STOCKS_DRRK")["board_type"] is None + assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" diff --git a/tests/test_settings.py b/tests/test_settings.py index 1f60b01..6a47edb 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -55,7 +55,7 @@ def test_init_from_config(conn): cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) keys = settings.init_from_config(conn, cfg) assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys - assert settings.get(conn, "base_jql") is None and settings.get(conn, "board_id") is None + assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn) assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802" assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"} assert settings.get(conn, "pacing") == settings.DEFAULT_PACING diff --git a/web/src/projects/NewProjectDialog.tsx b/web/src/projects/NewProjectDialog.tsx index 576aaf8..5d4d176 100644 --- a/web/src/projects/NewProjectDialog.tsx +++ b/web/src/projects/NewProjectDialog.tsx @@ -22,7 +22,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr return ( { e.preventDefault(); onClose(); }}> + onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>

Новый проект

@@ -32,7 +32,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
- + {text && {text}}
diff --git a/web/src/projects/projects.test.tsx b/web/src/projects/projects.test.tsx index d7a0a06..9214398 100644 --- a/web/src/projects/projects.test.tsx +++ b/web/src/projects/projects.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -18,7 +18,7 @@ const S = { label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: null, const ROW = { ...SNAP.issues[1], fetched_at: "" }; interface Call { method: string; url: URL; body: unknown } -function hub(projects: unknown[] = [A, S], over: Record Response> = {}) { +function hub(projects: unknown[] = [A, S], over: Record Response | Promise> = {}) { const calls: Call[] = []; const fetch = mockFetch((method, url, body) => { calls.push({ method, url, body }); @@ -49,6 +49,7 @@ describe("проект в приложении", () => { const h = hub(); const router = open("/app/board"); await waitFor(() => expect(router.state.location.search).toBe("?project=AWPCA")); + expect(router.state.historyAction).toBe("REPLACE"); await waitFor(() => expect(h.get("/board").length).toBeGreaterThan(0)); expect(h.get("/board")[0].url.searchParams.get("project")).toBe("AWPCA"); expect(localStorage.getItem(REMEMBER_KEY)).toBe("AWPCA"); @@ -59,6 +60,7 @@ describe("проект в приложении", () => { const router = open("/app/board?project=AWPCA"); await screen.findByTestId("page-board"); await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "STOCKS_DRRK"); + expect(router.state.historyAction).toBe("PUSH"); await waitFor(() => expect(h.get("/board").some((c) => c.url.searchParams.get("project") === "STOCKS_DRRK")).toBe(true)); const link = screen.getByRole("link", { name: "Задачи" }); expect(link).toHaveAttribute("href", "/app/issues?project=STOCKS_DRRK"); @@ -74,6 +76,7 @@ describe("проект в приложении", () => { hub(); const router = open("/app/issues"); await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + expect(router.state.historyAction).toBe("REPLACE"); }); it("неизвестный проект в адресе: сообщение и список проектов, без GET /issues", async () => { @@ -135,6 +138,44 @@ describe("проект в приложении", () => { expect(dlg).toHaveAttribute("open"); }); + it("ошибка фонового обновления проектов не сносит страницу: остаётся последний известный список", async () => { + let n = 0; + const h = hub([A, S], { + "GET /projects": () => (++n === 1 ? json(200, { projects: [A, S] }) : json(500, { error: "boom" })), + "POST /projects": () => json(201, { project: S, batch_id: 7 }), + }); + const router = open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); // инвалидация: повторный GET /projects падает + await waitFor(() => expect(router.state.location.search).toBe("?project=STOCKS_DRRK")); + expect(h.get("/projects").length).toBeGreaterThan(1); + expect(screen.getByRole("combobox", { name: "Проект" })).toHaveValue("STOCKS_DRRK"); + expect(await screen.findByTestId("page-board")).toBeInTheDocument(); + }); + + it("окно «Новый проект» во время создания не закрывается: «Отмена» заблокирована, Esc гасится", async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + hub([A], { "POST /projects": async () => { await gate; return json(201, { project: S, batch_id: 7 }); } }); + open("/app/board?project=AWPCA"); + await screen.findByTestId("page-board"); + await userEvent.selectOptions(screen.getByRole("combobox", { name: "Проект" }), "+ Новый проект…"); + const dlg = screen.getByRole("dialog", { name: "Новый проект" }); + await userEvent.type(within(dlg).getByLabelText("Метка"), "STOCKS_DRRK"); + await userEvent.type(within(dlg).getByLabelText("Название"), "Запасы ДРРК"); + await userEvent.click(within(dlg).getByRole("button", { name: "Создать" })); + await waitFor(() => expect(within(dlg).getByRole("button", { name: "Создать" })).toBeDisabled()); + expect(within(dlg).getByRole("button", { name: "Отмена" })).toBeDisabled(); + fireEvent(dlg, new Event("cancel", { cancelable: true })); + expect(screen.getByRole("dialog", { name: "Новый проект" })).toBeInTheDocument(); + release(); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Новый проект" })).not.toBeInTheDocument()); + }); + it("карточка по прямому адресу: выбор проекта не переписывает адрес и не закрывает окно", async () => { const h = hub(); const router = open("/app/issues/AURORA-9001"); diff --git a/web/src/shell/Layout.tsx b/web/src/shell/Layout.tsx index 3fe5e4f..f9ccdde 100644 --- a/web/src/shell/Layout.tsx +++ b/web/src/shell/Layout.tsx @@ -58,7 +58,7 @@ export function Layout() { const projects = useProjects(); const urlLabel = new URLSearchParams(pageLocation.search).get("project"); const state = useMemo(() => projects.isPending ? { kind: "loading" } - : projects.error ? { kind: "error", error: projects.error } + : projects.error && !projects.data ? { kind: "error", error: projects.error } // ошибка фонового рефетча — остаётся последний список : resolveProject(urlLabel, rememberedProject(), projects.data), [projects.isPending, projects.error, projects.data, urlLabel]); const okLabel = state.kind === "ok" ? state.project.label : null; const fromUrl = state.kind === "ok" && state.fromUrl;