From ef48e62953a642f214f16f7f37d6859beb82c108 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:34:04 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20v9:=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B3,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0,=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20=D0=B8=20=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=82=D1=8B=20(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- ARCHITECTURE.md | 7 +-- openspec/changes/sprints-backlog/design.md | 46 ++++++++++++++++++++ src/sdlc_hub/core/db.py | 7 +-- src/sdlc_hub/modules/jira/schema.py | 26 +++++++++++ tests/fixtures/baseline/schema.expected.json | 29 +++++++++++- tests/test_db.py | 41 +++++++++++++++-- 6 files changed, 145 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1004f56..879b2ab 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -18,7 +18,7 @@ | `http` | общее для обработчиков HTTP: `ApiError`, `Raw`, разбор параметров, `mapped`, `not_found` | `errors`, `projects` | | `modules` | контракт модуля: `Module` и `CommandHooks` (только структуры) | stdlib | | `registry` | реестр модулей: ленивая сборка, `modules()`, `module(name)`, `module_of(тип_команды)`, `agent_modules()`, `DEFAULT_AGENT`, проверка конфликтов (типы команд, маршруты, ключи настроек, поля проекта) | `modules`; модули — ленивым импортом внутри функции | -| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v8; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | +| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v9; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | | `settings` | настройки (`setting`), токены (клиентский + по агенту на модуль: `setting.tokens = {client, agents: {<модуль>: <токен>}}`), умолчания модулей (`ensure_defaults`) | `registry` (база — через `conn` вызывающего) | | `projects` | проекты-метки: таблица `project` (`label`, `name`, `created_at`), проверка метки, создание и правка (`PATCH`) с полями модулей, список = поля ядра + `project_view` модулей | `db`, `registry` | | `queue` | механика очереди: пакеты, выдача (`claim`), отчёты, отмена, повтор, временные ключи `new:`; смысл типов команд берёт у модуля через `CommandHooks` (`registry.module_of`) | `backup`, `db`, `registry`, `settings`, `errors` | @@ -37,7 +37,7 @@ | Модуль | Ответственность | Зависит от | |---|---|---| -| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run` и др. | stdlib | +| `schema` | таблицы модуля: `issue`, `issue_link`, `issue_comment`, `issue_attachment`, `attachment_file`, `issue_change`, `issue_note`, `sync_run`, `jira_board`, `jira_sprint`, `issue_sprint` и др. | stdlib | | `settings` | умолчания настроек модуля, ключи для агента, проверка значений, темп (`pacing`), перенос из `config.example.json` (`init_from_config`) | `core.settings`, `core.projects`, `projects` | | `mirror` | зеркало задач, связей, комментариев и вложений; пишет его только `apply_state` (с проверками `check_comments`, `check_attachments`), разрез по проекту, заметки, фильтры, снимок доски `board`, карточка `get_issue` | `core.db`, `history`, `attachments` | | `history` | журнал изменений `issue_change`: время Jira → UTC (`jira_utc`), проверка и запись пачек агента, `delivery_param` для выдачи `refresh`, лента `feed`, история задачи | `core.db`, `core.settings`, `projects` | @@ -152,10 +152,11 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `core/api`, `modules/*/api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. Модуль веба не импортирует другой модуль; общее — в `shell`, `ui`, `api`, `projects`. -## Выкатка и откат версии с новой схемой (v7, v8) +## Выкатка и откат версии с новой схемой (v7, v8, v9) - Порядок: `git pull` → сразу `launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub` (владелец) → `make web` → `/mcp` reconnect. Старый фронт с новым API работает (поля `board_id` и прочие на месте), поэтому сборку можно отложить. - До kickstart не запускать MCP и CLI `sdlc-hub` (в том числе `tokens`): `HubClient.from_home()` и CLI открывают базу через `db.connect` и мигрируют её, а хаб на прежнем коде с такой базой не работает (v7 — `agent_status`, токены; v8 — нет колонки `issue_note.fact_status`: 500 на `GET /issues`, `/board`, запись заметки). - После kickstart MCP-сессии со старым кодом до reconnect открывают базу своими умолчаниями: ключи, удалённые миграцией, они могут вернуть — их убирает `ensure_defaults` нового кода (`Module.settings_obsolete`). +- v9 (sprints-backlog): колонки `issue.rank`, `issue.story_points` и таблицы `jira_board`, `jira_sprint`, `issue_sprint` добавляются миграцией; прежний код с базой v9 работает — новые колонки и таблицы он не читает, поэтому откат кода делается без возврата копии базы. - Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. ## Открытые вопросы diff --git a/openspec/changes/sprints-backlog/design.md b/openspec/changes/sprints-backlog/design.md index c76eca7..89d62ed 100644 --- a/openspec/changes/sprints-backlog/design.md +++ b/openspec/changes/sprints-backlog/design.md @@ -173,3 +173,49 @@ kanban) — свои задачи в статусах остальных кол - 24.10.2026 — R-28 (план агента R-64, R-65): поле Jira не найдено — ключ у задач не передаётся (хаб сохраняет прежнее), а не `null`; ошибка чтения списка спринтов — доска не входит в `boards` (иначе стёрлась бы история спринтов). Цена ошибки — нет. +- 24.10.2026 — R-13 (контракт ядра): `CommandHooks.tail` получает `(conn, cmds, project)` вместо `(cmds)`: хвост + пакета со спринтовыми командами — `refresh` по JQL проекта пакета (design #13), а без соединения и проекта хук JQL + не построит. Правится ядро (`core/modules.py`, `core/queue.py`), wiki (`tail=lambda conn, cmds, project: None`) и + две заглушки в тестах. Цена ошибки — вернуть сигнатуру и строить хвост в `check_batch` (дописывая `refresh` в конец + пакета). +- 24.10.2026 — R-14 (форма `boards`): у доски в `boards` поле `sprints` отсутствует или `null` — спринты доски в + зеркале не меняются (агент не смог их прочитать, agent spec «прежние правила для спринтов»); `[]` — у доски спринтов + нет, строки удаляются. `columns` отсутствует или `null` — пишется `NULL`. `boards: null` в пачке — как отсутствие. + Цена ошибки — устаревшие спринты на экране до следующего `refresh` с `sprints: []`. +- 24.10.2026 — R-15 (проверки постановки): хаб проверяет `start_date`/`end_date` (`ГГГГ-ММ-ДД`, настоящая дата, начало + не позже конца), непустые `sprint`/`rename_to`, `state` ∈ {`active`, `closed`}, `move_open_to` (обязателен при + `closed`, 422 при `active`, не равен `sprint`), у `ensure_rank` ровно одно из `before`/`after` и не сама задача, + `story_points` — конечное число ≥ 0 или `null`. Цена ошибки — правка проверок. +- 24.10.2026 — R-16 (подтверждение по зеркалу): `ensure_fields` со `story_points` подтверждается, только если + `issue.story_points == params.story_points` точно (`null` = `null`, `0` ≠ `null`); прочие поля — как раньше. Цена + ошибки — лишняя довыдача. +- 24.10.2026 — R-17 (`GET /agile`): `board_type = null` → `sprints: []`, `backlog: null`, `board_issues: null`; + scrum-бэклог исключает задачи в активном/будущем спринте любой доски из `jira_sprint` (спринт, которого нет в + `jira_sprint`, не учитывается); kanban: выполненная задача без `jira_updated` не показывается; `kanban_backlog` в + ответе — `true`/ `false`/`null`; суммы `points`/`points_done` — сумма чисел (без оценок — `0`). Цена ошибки — правка + выборки. +- 24.10.2026 — R-18 (Kanban с бэклогом): при `kanban_backlog = true` первая колонка доски не рисуется — её задачи в + «Бэклог (N)» (как kanban-бэклог Jira). Цена ошибки — вернуть колонку. +- 24.10.2026 — R-19 (три состояния): «Нужен агент v15 и обновление из Jira» — `board = null` или `board.fetched_at = + null`; «Не удалось прочитать настройки доски из Jira» — `fetched_at` есть, `columns = null` (design #23 вместо + противоречивой фразы web-app «`board.columns = null` или ответ без доски»). Цена ошибки — правка условия. +- 24.10.2026 — R-20 (кнопка «Обновить»): на экранах — существующая `RefreshButton` («Обновить из Jira», в шапке и в + сообщениях трёх состояний). Правило «кнопок нет, кроме раскрытия секций и карточки» к ней не относится: она читает + Jira, а не пишет. Цена ошибки — убрать кнопку из шапки. +- 24.10.2026 — R-21 (переименование): «Связи» — подпись пункта меню, заголовок вкладки и ссылка в карточке jira на + «Обзоре»; заголовок `h1` страницы остаётся «Доска связей» (спека говорит о меню и вкладке). Цена ошибки — правка + `BoardHeader` и e2e `geometry.spec.ts`. +- 24.10.2026 — R-22 (смена проекта): фраза «Проект в приложении» («смена проекта на странице, которой нет у нового + проекта, открывает «Обзор»») не меняется в коде: других условных страниц нет, а для экранов доски действует design + #21 (адрес остаётся, сообщение «У проекта нет …-доски») — `Layout.select` уже сохраняет раздел. Цена ошибки — + переход на «Обзор» для новых страниц. +- 24.10.2026 — R-23 (NotFound): «не найдено» перечисляет только безусловные пункты (без `when`). Цена ошибки — нет. +- 24.10.2026 — R-24 (склонение): «N задач» — с русским склонением (1 задача, 2 задачи, 5 задач). Цена ошибки — нет. +- 24.10.2026 — R-25 (тексты MCP): шапки таблиц и отказы `sprints`/`sprint`/`backlog` — по задаче 7. Цена ошибки — + правка текста. +- 24.10.2026 — R-26 (даты): хаб сортирует спринты по `history.jira_utc(дата)`; веб перед `new Date` приводит `±HHMM` к + `±HH:MM`. Цена ошибки — нет. +- 24.10.2026 — R-27 (e2e): тестовый хаб засевает второй проект `KANBAN_E2E` (доска 5120, kanban, задачи + AURORA-9101…9103); доска AWPCA — scrum со спринтами 101 (закрыт), 102 (активный), 103 (будущий). Цена ошибки — + правка сида. +- 24.10.2026 — R-29: трейлер коммитов — Co-Authored-By: Claude Sonnet 5.5 (инструкция сессии точнее перечня плана). Цена ошибки — переписать трейлеры, ничего не пушилось. diff --git a/src/sdlc_hub/core/db.py b/src/sdlc_hub/core/db.py index 45a88fc..b4b34b1 100644 --- a/src/sdlc_hub/core/db.py +++ b/src/sdlc_hub/core/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 8 +SCHEMA_VERSION = 9 CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -116,11 +116,11 @@ def schema() -> str: return CORE_SCHEMA + "".join(m.schema for m in registry.modules()) -def _add_column(table: str, column: str): +def _add_column(table: str, column: str, decl: str = "TEXT"): """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из схемы уже с новой колонкой.""" def step(conn) -> None: if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT") + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}") return step @@ -136,6 +136,7 @@ MIGRATIONS = { 6: [_tokens_v7, _agent_status_v7, _project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) 7: [_note_fields_v8], # v7 → v8: без статуса по факту и его настроек (fact-status-removal) + 8: [_add_column("issue", "rank"), _add_column("issue", "story_points", "REAL")], # v8 → v9: ранг и оценка задачи; таблицы досок и спринтов создаются схемой (sprints-backlog) } diff --git a/src/sdlc_hub/modules/jira/schema.py b/src/sdlc_hub/modules/jira/schema.py index e728f11..44c4fc6 100644 --- a/src/sdlc_hub/modules/jira/schema.py +++ b/src/sdlc_hub/modules/jira/schema.py @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS issue ( epic_key TEXT, labels TEXT NOT NULL DEFAULT '[]', sprint TEXT, + rank TEXT, + story_points REAL, created TEXT, jira_updated TEXT, fetched_at TEXT NOT NULL, @@ -102,4 +104,28 @@ CREATE TABLE IF NOT EXISTS jira_project ( board_id INTEGER, board_type TEXT ); +CREATE TABLE IF NOT EXISTS jira_board ( + board_id INTEGER PRIMARY KEY, + type TEXT, + columns TEXT, + kanban_backlog INTEGER, + fetched_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS jira_sprint ( + sprint_id INTEGER PRIMARY KEY, + board_id INTEGER NOT NULL, + name TEXT NOT NULL, + state TEXT NOT NULL, + start_date TEXT, + end_date TEXT, + complete_date TEXT, + goal TEXT +); +CREATE INDEX IF NOT EXISTS ix_jira_sprint_board ON jira_sprint(board_id); +CREATE TABLE IF NOT EXISTS issue_sprint ( + key TEXT NOT NULL, + sprint_id INTEGER NOT NULL, + PRIMARY KEY (key, sprint_id) +); +CREATE INDEX IF NOT EXISTS ix_issue_sprint_sprint ON issue_sprint(sprint_id); """ diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index c7288d8..ca2c5fe 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -25,6 +25,16 @@ "ix_issue_link_target", "CREATE INDEX ix_issue_link_target ON issue_link(target_key)" ], + [ + "index", + "ix_issue_sprint_sprint", + "CREATE INDEX ix_issue_sprint_sprint ON issue_sprint(sprint_id)" + ], + [ + "index", + "ix_jira_sprint_board", + "CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)" + ], [ "table", "agent_status", @@ -48,7 +58,7 @@ [ "table", "issue", - "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" + "CREATE TABLE issue (\n key TEXT PRIMARY KEY,\n id TEXT,\n issue_type TEXT,\n summary TEXT,\n description TEXT,\n status TEXT,\n priority TEXT,\n assignee TEXT,\n reporter TEXT,\n duedate TEXT,\n planned_start TEXT,\n planned_end TEXT,\n epic_key TEXT,\n labels TEXT NOT NULL DEFAULT '[]',\n sprint TEXT,\n rank TEXT,\n story_points REAL,\n created TEXT,\n jira_updated TEXT,\n fetched_at TEXT NOT NULL,\n neighbor INTEGER NOT NULL DEFAULT 0\n)" ], [ "table", @@ -75,11 +85,26 @@ "issue_note", "CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)" ], + [ + "table", + "issue_sprint", + "CREATE TABLE issue_sprint (\n key TEXT NOT NULL,\n sprint_id INTEGER NOT NULL,\n PRIMARY KEY (key, sprint_id)\n)" + ], + [ + "table", + "jira_board", + "CREATE TABLE jira_board (\n board_id INTEGER PRIMARY KEY,\n type TEXT,\n columns TEXT,\n kanban_backlog INTEGER,\n fetched_at TEXT NOT NULL\n)" + ], [ "table", "jira_project", "CREATE TABLE jira_project (\n label TEXT PRIMARY KEY REFERENCES project(label),\n board_id INTEGER,\n board_type TEXT\n)" ], + [ + "table", + "jira_sprint", + "CREATE TABLE jira_sprint (\n sprint_id INTEGER PRIMARY KEY,\n board_id INTEGER NOT NULL,\n name TEXT NOT NULL,\n state TEXT NOT NULL,\n start_date TEXT,\n end_date TEXT,\n complete_date TEXT,\n goal TEXT\n)" + ], [ "table", "project", @@ -116,5 +141,5 @@ "CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)" ] ], - "schema_version": 8 + "schema_version": 9 } diff --git a/tests/test_db.py b/tests/test_db.py index 3c67f36..4b86003 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,6 +19,8 @@ def test_connect_creates_schema(open_db): assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} + assert {"jira_board", "jira_sprint", "issue_sprint"} <= names + assert {"rank", "story_points"} <= {r["name"] for r in conn.execute("PRAGMA table_info(issue)")} assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} @@ -402,7 +404,7 @@ def test_migrates_v7_drops_fact_status(tmp_path): path = tmp_path / "hub.sqlite" _v7_database(path) conn = db.connect(path) - assert db._schema_version(conn) == 8 + assert db._schema_version(conn) == db.SCHEMA_VERSION assert _note_after_v8(conn, "A-1") == "Аналитика" row = conn.execute("SELECT updated_at, updated_by FROM issue_note WHERE key = 'A-1'").fetchone() assert (row["updated_at"], row["updated_by"]) == ("2026-10-20T00:00:00+00:00", "import") @@ -416,7 +418,7 @@ def test_migrates_v7_without_keys_keeps_settings_version(tmp_path): path = tmp_path / "hub.sqlite" _v7_database(path, with_keys=False) conn = db.connect(path) - assert db._schema_version(conn) == 8 and settings.get(conn, "settings_version") == 12 + assert db._schema_version(conn) == db.SCHEMA_VERSION and settings.get(conn, "settings_version") == 12 conn.close() @@ -428,7 +430,7 @@ def test_migrates_v7_without_settings_version(tmp_path): raw.commit() raw.close() conn = db.connect(path) - assert db._schema_version(conn) == 8 + assert db._schema_version(conn) == db.SCHEMA_VERSION assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0 conn.close() @@ -457,3 +459,36 @@ def test_obsolete_keys_removed_by_ensure_defaults(tmp_path): assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn)) assert settings.get(conn, "settings_version") == version conn.close() + + +def _v8_database(path): + """База v8: задача X со спринтом «Спринт 2», без колонок rank/story_points и таблиц досок (sprints-backlog).""" + conn = db.connect(path) + settings.ensure_defaults(conn) + conn.execute("INSERT INTO issue(key, summary, sprint, labels, fetched_at) VALUES ('X', 'т', 'Спринт 2', '[]', ?)", (db.now(),)) + for col in ("rank", "story_points"): + conn.execute(f"ALTER TABLE issue DROP COLUMN {col}") + for t in ("jira_board", "jira_sprint", "issue_sprint"): + conn.execute(f"DROP TABLE {t}") + conn.execute("UPDATE setting SET value = '8' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v8_adds_agile_columns(tmp_path): # сценарий «Миграция базы v8» + path = tmp_path / "hub.sqlite"; _v8_database(path) + conn = db.connect(path) + assert db._schema_version(conn) == 9 + row = conn.execute("SELECT rank, story_points, sprint FROM issue WHERE key = 'X'").fetchone() + assert (row["rank"], row["story_points"], row["sprint"]) == (None, None, "Спринт 2") + types = {r["name"]: r["type"] for r in conn.execute("PRAGMA table_info(issue)")} + assert types["rank"] == "TEXT" and types["story_points"] == "REAL" + for t in ("jira_board", "jira_sprint", "issue_sprint"): + assert conn.execute(f"SELECT COUNT(*) AS n FROM {t}").fetchone()["n"] == 0 + assert len(list((tmp_path / "backups").glob("*-schema-v8.sqlite"))) == 1 + conn.close() + + +def test_v9_reconnect_is_stable(tmp_path): # повторный connect не мигрирует и не копирует + path = tmp_path / "hub.sqlite"; _v8_database(path) + db.connect(path).close(); db.connect(path).close() + assert len(list((tmp_path / "backups").glob("*-schema-v*.sqlite"))) == 1