Чистка находок ревью задач: типы досок в любом статусе, мелкие правки тестов и веб-приложения (#61)

R-3/T6.1 типы досок из результата refresh пишутся в любом статусе отчёта; T5.1, T3.1 (author), T3.2, T2.1, T2.2, T7.2,
T8.1, T8.2 (ошибка фонового рефетча проектов не сносит страницу), T8.4 (окно «Новый проект» не закрывается во время
создания). Решения 22, 27 переписаны, добавлено 36 в design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 21:41:22 +04:00
parent 57145b22c9
commit 1b441b79a5
10 changed files with 92 additions and 19 deletions

View File

@ -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:<n>` в зеркале — не задача 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}».**
Обоснование: ошибка пакетная, а не одной команды. Цена ошибки: только текст сообщения.

View File

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

View File

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

View File

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

View File

@ -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("агент ") # служебные инструменты работают без привязки

View File

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

View File

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

View File

@ -22,7 +22,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
return (
<dialog ref={ref} className="project-dialog" aria-label="Новый проект"
onCancel={(e) => { e.preventDefault(); onClose(); }}>
onCancel={(e) => { e.preventDefault(); if (!create.isPending) onClose(); }}>
<form onSubmit={submit}>
<h2>Новый проект</h2>
<label>Метка<input value={label} onChange={(e) => setLabel(e.target.value)} required /></label>
@ -32,7 +32,7 @@ export function NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCr
</label>
<div className="actions">
<button className="btn" type="submit" disabled={create.isPending}>Создать</button>
<button className="btn" type="button" onClick={onClose}>Отмена</button>
<button className="btn" type="button" onClick={onClose} disabled={create.isPending}>Отмена</button>
{text && <span className="warn" role="alert">{text}</span>}
</div>
</form>

View File

@ -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<string, (body: unknown) => Response> = {}) {
function hub(projects: unknown[] = [A, S], over: Record<string, (body: unknown) => Response | Promise<Response>> = {}) {
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<void>((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");

View File

@ -58,7 +58,7 @@ export function Layout() {
const projects = useProjects();
const urlLabel = new URLSearchParams(pageLocation.search).get("project");
const state = useMemo<ProjectState>(() => 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;