diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eacff13..d8908ec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -47,7 +47,7 @@ | `refresh` | журнал синхронизаций `sync_run`, полный прогон, автообновление | `core.db`, `core.queue`, `core.settings`, `projects` | | `projects` | базовая JQL из меток (`base_jql`), доски (`boards`), миграция v3 → v4 (`migrate_v3`), проект по умолчанию | `core.projects`, `core.settings` | | `hooks` | `CommandHooks` для очереди ядра: проверки постановки (`bug` без настройки, файл вложения, проект пакета, `board_id` у `ensure_sprint` (по проекту задачи) и у `ensure_sprint_meta`/`ensure_sprint_state` (по проекту пакета)), паузы, `deliver_params` (`size`, `boards`, `history`), отчёты (`attachment_file`, `sync_run`) | `core.queue`, `core.files`, `core.projects`, `core.settings`, `settings`, `projects`, `commands`, `mirror`, `history`, `refresh`, `attachments`, `fetch` | -| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `core.db`, `history` | +| `agile` | чтение для `GET /agile`: доска, спринты, бэклог, выполнено по последней колонке | `history` | | `api` | маршруты модуля `MODULE.routes`: `/issues`, `/board`, `/agile`, `/changes`, `/attachments`, `/refresh`, `/sync-runs` и др. | `core.db`, `core.files`, `core.queue`, `core.settings`, `core.http`, `core.errors`, `attachments`, `projects`, `mirror`, `history`, `fetch`, `refresh` | | `mcp` | инструменты MCP: `issues` (колонка SP), `issue`, `sprints`, `sprint`, `backlog` (читают `GET /agile`), `batch_create`, `batch_*`, `comment`, `attachment`, `changes`, `refresh`, `settings_*` и др.; общие `projects`/`project_create` подключает `register_core_tools` | `core.mcp`, `core.client`, `commands`, `attachments` | | `importer`, `cli` | разовый импорт CSV-пакета в заметки (`sdlc-hub import FOLDER`) | `mirror`, `core.db`, `core.settings` | diff --git a/README.md b/README.md index 8cf93c5..d3dbb30 100644 --- a/README.md +++ b/README.md @@ -341,9 +341,9 @@ make web `ensure_comment`, `ensure_status`, `ensure_link`, `ensure_sprint`, `ensure_attachment`, `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank` 30 с, `refresh` 2 с (настройка `pacing`). -Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) и `ensure_rank` ставятся только в пакете проекта (поле `project`): -`board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; -доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. `ensure_rank` в пакете проекта — `key` своей задачи. +Команды спринта (`ensure_sprint_meta`, `ensure_sprint_state`) ставятся только в пакете проекта (поле `project`); `ensure_rank` — в любом пакете. +У команд спринта `board_id` дописывает хаб по проекту пакета; пакет без проекта — 422 «команды спринта — только в пакете проекта»; +доски нет, kanban или тип не известен — 422, как у `ensure_sprint`. В пакете проекта у `ensure_rank` `key` должен быть своей задачей. Даты проверяются хабом: настоящая дата, начало не позже конца. `ensure_attachment` ставится, только если файл уже загружен (`POST /files`), иначе 422 «команда N: файл не @@ -362,7 +362,8 @@ make web (владелец; миграция схемы v9) → `make web` → `/mcp` reconnect → `agent publish` агента v15 (`uv run sdlc-hub agent publish ../jira-agent/src/agent.py`) → проверка полей Jira банка (ранг, оценка, спринты) → `field_ids.story_points` в настройках, если агент поле оценки не нашёл. `min_agent_version` поднимает владелец после выкатки v15 и после того, как `agent_status` - покажет новую версию. + покажет новую версию. Ограничение (R-33): до публикации агента v15 `story_points` в `ensure_fields` агент v14 выполнит как + `done` с пустым `changed`; v15 публикуется сразу после перезапуска хаба. - Резервные копии: перед первой записывающей командой пакета хаб копирует базу в `backups/<дата-время>-.sqlite` и хранит последние `backup_keep` копий (по умолчанию 30). - Файлы вложений: `files/`, одна копия на содержимое, в резервные копии базы не входят. Раз в сутки (первый diff --git a/src/sdlc_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py index d8c5e7b..a5317f6 100644 --- a/src/sdlc_hub/modules/jira/commands.py +++ b/src/sdlc_hub/modules/jira/commands.py @@ -81,6 +81,13 @@ def temp_keys_used(cmd) -> list[str]: return [k for k in referenced_keys(cmd) if is_temp(k) and k != own] +def _finite(value) -> bool: + try: + return math.isfinite(value) + except OverflowError: # целое 10**400 не приводится к float + return False + + def _type_error(ctype, name, value) -> str | None: """Проверка типа значения параметра (M-3 #12): иначе .strip() и подстановка ключей падают с 500. null допустим везде — обязательность проверяется отдельно.""" @@ -91,7 +98,7 @@ def _type_error(ctype, name, value) -> str | None: if name in BOOL_PARAMS: return None if isinstance(value, bool) else f"{name} — true или false" if name in NUMBER_PARAMS: # конечное число ≥ 0; bool, NaN и Infinity — не число (R-15) - ok = isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) and value >= 0 + ok = isinstance(value, (int, float)) and not isinstance(value, bool) and _finite(value) and value >= 0 return None if ok else f"{name} — число ≥ 0 или null" if ctype == "ensure_status" and name == "target": return None if isinstance(value, str) or _is_str_list(value) else f"{name} — строка или список строк" @@ -122,23 +129,26 @@ def _rule_error(ctype, p) -> str | None: if start and end and start > end: return "start_date позже end_date" if ctype == "ensure_sprint_state": + if not p["sprint"].strip(): + return "sprint — непустое имя" if p["state"] not in ("active", "closed"): return "state — active или closed" - move = p.get("move_open_to") + move = (p.get("move_open_to") or "").strip() if p["state"] == "closed": - if move in (None, ""): + if not move: return "move_open_to обязателен при state = closed" - if move == p["sprint"]: + if move == p["sprint"].strip(): return "move_open_to — другой спринт" - elif move not in (None, ""): + elif move: return "move_open_to — только при state = closed" if ctype == "ensure_rank": - before, after = p.get("before") or None, p.get("after") or None + before = (p.get("before") or "").strip() or None # пустая и пробельная строка — «не задано» (R-31) + after = (p.get("after") or "").strip() or None if bool(before) == bool(after): return "ровно одно из before, after" - if before == p["key"]: + if before == p["key"].strip(): return "before — другая задача" - if after == p["key"]: + if after == p["key"].strip(): return "after — другая задача" return None diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index eb1ec25..90da1a3 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -55,6 +55,21 @@ def check_attachments(issues) -> None: raise ValueError(f"{where}.{f} — строка или null") +_INT_MAX = 2**63 - 1 # диапазон SQLite INTEGER + + +def _in_range(v) -> bool: + return -2**63 <= v <= _INT_MAX + + +def _finite(v) -> bool: + """Конечное число; огромные целые (10**400) не падают с OverflowError, а считаются неподходящими.""" + try: + return math.isfinite(v) + except OverflowError: + return False + + def check_agile_fields(issues) -> None: """Форма полей rank, story_points, sprint_ids у задач состояния (агент v15+); поля необязательны (не присланное не проверяется). Нарушение — ValueError с ключом и полем.""" @@ -64,16 +79,16 @@ def check_agile_fields(issues) -> None: raise ValueError(f"{key}: rank — строка или null") if "story_points" in it and it["story_points"] is not None: sp = it["story_points"] - if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not math.isfinite(sp): + if isinstance(sp, bool) or not isinstance(sp, (int, float)) or not _finite(sp) or (isinstance(sp, int) and not _in_range(sp)): raise ValueError(f"{key}: story_points — число или null") if "sprint_ids" in it: ids = it["sprint_ids"] - if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) for i in ids): + if not isinstance(ids, list) or any(isinstance(i, bool) or not isinstance(i, int) or not _in_range(i) for i in ids): raise ValueError(f"{key}: sprint_ids — список целых") def _is_id(v) -> bool: - return isinstance(v, int) and not isinstance(v, bool) and v > 0 + return isinstance(v, int) and not isinstance(v, bool) and 0 < v <= _INT_MAX def check_boards(boards) -> None: diff --git a/tests/test_api.py b/tests/test_api.py index 94300bb..efc2a39 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1228,6 +1228,8 @@ def test_sweep_files_downloads(hub): ({"story_points": "3"}, "A-1: story_points"), ({"story_points": True}, "A-1: story_points"), ({"story_points": float("nan")}, "A-1: story_points"), ({"story_points": float("inf")}, "A-1: story_points"), # Review Focus 5 ({"rank": 5}, "A-1: rank"), + ({"story_points": 10**400}, "A-1: story_points"), ({"story_points": 2**63}, "A-1: story_points"), + ({"sprint_ids": [2**63]}, "A-1: sprint_ids"), ({"sprint_ids": [-2**63 - 1]}, "A-1: sprint_ids"), ]) def test_agent_state_bad_agile_fields_422(hub, over, detail): with pytest.raises(HubError) as e: @@ -1242,16 +1244,19 @@ def test_agent_state_bad_agile_fields_422(hub, over, detail): "доска 4216: sprints[0].state — active, future или closed"), ([{"id": 4216, "type": "sprint"}], "доска 4216: type"), ([{"id": 4216, "type": "kanban", "kanban_backlog": "да"}], "доска 4216: kanban_backlog"), + ([{"id": 2**63}], "boards[0].id"), + ([{"id": 4216, "sprints": [{"id": 2**63, "name": "S", "state": "active"}]}], "sprints[0].id"), ]) def test_agent_state_bad_boards_422(hub, boards, detail): with pytest.raises(HubError) as e: - hub["agent"].post("/agent/state", {"issues": [], "links": [], "boards": boards}) + hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": []}], "links": [], "boards": boards}) assert e.value.status == 422 and e.value.error == "bad_state" and detail in e.value.detail c = hub["hub"].conn() try: assert c.execute("SELECT COUNT(*) FROM jira_board").fetchone()[0] == 0 finally: c.close() + assert hub["client"].get("/issues")["issues"] == [] # пачка не применена def test_agent_state_empty_batch_with_boards(hub): diff --git a/tests/test_commands.py b/tests/test_commands.py index 3254a7e..a55c623 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -298,11 +298,22 @@ def test_new_types_validate_ok(): ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"), ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5 ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"), + ({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError + ({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "sprint — непустое имя"), + (state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"), + (state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"), + (state(state="active", move_open_to="x"), "move_open_to — только при state = closed"), + (rank(before=" ", after=" "), "ровно одно из before, after"), + (rank(before=" AURORA-1612 "), "before — другая задача"), ]) def test_new_types_errors(cmd, msg): with pytest.raises(CommandError, match=re.escape(msg)): commands.validate([cmd]) +def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано» + assert commands.validate([state(state="active", move_open_to=" ")]) + assert commands.validate([rank(before="A-2", after=" ")]) + def test_rank_temp_keys(): ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")] assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 9e2c76b..59781ad 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -926,8 +926,9 @@ def test_fmt_backlog_limit(): # сценарий « def test_fmt_day_offsets(): - assert jmcp.fmt_day("2026-10-14T09:00:00.000+0300") == jmcp.fmt_day("2026-10-14T09:00:00.000+03:00") != "—" - assert jmcp.fmt_day("2026-10-14T09:00:00.000Z") != "—" + want = datetime(2026, 10, 14, 12, tzinfo=timezone.utc).astimezone().strftime("%d.%m") # один момент во всех записях + for raw in ("2026-10-14T12:00:00.000Z", "2026-10-14T15:00:00.000+0300", "2026-10-14T15:00:00.000+03:00"): + assert jmcp.fmt_day(raw) == want assert jmcp.fmt_day(None) == "—" and jmcp.fmt_day("мусор") == "—" diff --git a/web/e2e/agile.spec.ts b/web/e2e/agile.spec.ts index 8ea5ce4..5c03720 100644 --- a/web/e2e/agile.spec.ts +++ b/web/e2e/agile.spec.ts @@ -26,7 +26,7 @@ test("«Бэклог» AWPCA: секции, «Прошлые спринты (1) test("«Спринт» AWPCA: колонки доски, «осталось 7 дн.», карточка окном", async ({ page }) => { await login(page, "/app/jira/sprint?project=AWPCA"); await expect(page.getByRole("heading", { name: "AWPCA_Спринт 2" })).toBeVisible(); - await expect(page.getByText("осталось 7 дн.")).toBeVisible(); + await expect(page.getByText(/осталось \d+ дн\./)).toBeVisible(); for (const name of ["К выполнению", "В работе", "Готово"]) { await expect(page.locator(".agile-col__name", { hasText: name })).toBeVisible(); } diff --git a/web/src/modules/jira/agile/KanbanPage.test.tsx b/web/src/modules/jira/agile/KanbanPage.test.tsx index 31294ff..85eafb4 100644 --- a/web/src/modules/jira/agile/KanbanPage.test.tsx +++ b/web/src/modules/jira/agile/KanbanPage.test.tsx @@ -22,7 +22,9 @@ const agile = (backlog: boolean | null): AgileResponse => ({ board: { id: 5120, type: "kanban", columns: cols, kanban_backlog: backlog, fetched_at: "2026-10-24T10:00:00+00:00" }, sprints: [], backlog: backlog ? [iss("AURORA-1"), iss("AURORA-2")] : null, - board_issues: [iss("AURORA-1"), iss("AURORA-2"), iss("AURORA-3", { status: "В работе" }), iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })], + // как отвечает сервер: при kanban_backlog = true задачи первой колонки есть только в backlog, не в board_issues + board_issues: [...(backlog ? [] : [iss("AURORA-1"), iss("AURORA-2")]), iss("AURORA-3", { status: "В работе" }), + iss("AURORA-4", { status: "Готово", done: true }), iss("AURORA-5", { status: "Странный" })], }); function hub(data: AgileResponse, projects = [K]) { diff --git a/web/src/modules/jira/agile/KanbanPage.tsx b/web/src/modules/jira/agile/KanbanPage.tsx index 16c48d9..8dc910a 100644 --- a/web/src/modules/jira/agile/KanbanPage.tsx +++ b/web/src/modules/jira/agile/KanbanPage.tsx @@ -16,9 +16,7 @@ export function KanbanPage({ project }: { project: Project }) { {(d) => { const withBacklog = d.board?.kanban_backlog === true; const cols = d.board?.columns ?? null; - // задачи первой колонки уходят в «Бэклог», а не в «Без колонки» - const first = new Set(withBacklog && cols ? cols[0]?.statuses ?? [] : []); - const issues = (d.board_issues ?? []).filter((i) => i.status === null || !first.has(i.status)); + const issues = d.board_issues ?? []; // сервер уже убрал задачи первой колонки, если бэклог включён const backlog = d.backlog ?? []; return ( <> diff --git a/web/src/modules/jira/agile/SprintPage.test.tsx b/web/src/modules/jira/agile/SprintPage.test.tsx index 0e24302..55cef31 100644 --- a/web/src/modules/jira/agile/SprintPage.test.tsx +++ b/web/src/modules/jira/agile/SprintPage.test.tsx @@ -19,7 +19,9 @@ const iss = (key: string, over: Partial = {}): AgileIssue => ({ const iso = (offsetDays: number) => { const d = new Date(Date.now() + offsetDays * 86_400_000); const p = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000+0300`; + d.setHours(12, 0, 0, 0); // полдень местного времени и местное смещение: результат не зависит от пояса и часа суток + const off = -d.getTimezoneOffset(), a = Math.abs(off); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T12:00:00.000${off < 0 ? "-" : "+"}${p(Math.floor(a / 60))}${p(a % 60)}`; }; const sprint = (over: Partial): AgileSprint => ({ id: 1, name: "S", state: "active", start_date: "2026-10-12T09:00:00.000+0300", end_date: iso(3),