diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4530ed8..1004f56 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` (v7; копия базы перед миграцией; история шагов `MIGRATIONS`) | `backup`, `paths`; модули — только внутри шагов истории миграций (ленивый импорт) | +| `db` | `connect`, схема ядра (`setting`, `batch`, `command`, `project`, `agent_status`) и схемы модулей из `Module.schema`, миграции по `schema_version` (v8; копия базы перед миграцией; история шагов `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` | @@ -75,7 +75,7 @@ `core/modules.py` задаёт две структуры; ядро знает только их. -- `Module(name, schema, settings_defaults, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. +- `Module(name, schema, settings_defaults, settings_obsolete, agent_keys, agent_min_version_key, validate_setting, settings_view, on_init, commands, routes, tick, file_refs, project_fields, project_view, on_project_created, on_project_updated, mcp_server, cli_register, cli_run)` — всё необязательное, кроме `name`. Модуль с `commands` — «вид агента»: у него свой агент, токен, `agent_status` и релиз. - Поля проекта: ядро знает только `label`, `name`, `created_at`. Остальное объявляет модуль: `ProjectField(name, validate, editable)`; значения для `GET /projects` (в т.ч. вычисляемые) отдаёт `project_view(conn, labels)`; `on_project_created(conn, project, fields, author)` и `on_project_updated(...)` пишут свои поля и возвращают пакеты. `POST /projects` принимает поля всех модулей (неизвестное — 422), `PATCH /projects/{label}` — только изменяемые (`editable`). `registry.check` падает при совпадении имени поля у двух модулей или с полем ядра. - `CommandHooks` — что модуль знает о своих типах команд: `types`, `read_types` (не пишут во внешнюю систему: без хвоста и копии базы), `validate`, `check_batch`, `tail`, `pause`, `deliver_params`, `temp_definer`, `temp_keys_used`, `substitute`, `drop_unresolved`, `retry_params`, `check_confirmed`, `on_claim`, `on_failed`, `check_result`, `on_result`, `after_result`, `on_late_result` и два необязательных — `before_result`, `abort_result`. Очередь вызывает их по типу команды через `registry.module_of`. - `before_result(conn, cmd, status, result, files_dir)` ядро вызывает в `report_result` до транзакции отчёта, только для выданной (`claimed`) команды. Нужен, чтобы тяжёлая подготовка (сборка копии вики, запись файлов) шла вне `BEGIN IMMEDIATE` и не держала блокировку базы, в которой ждут `claim` другого агента и веб (рулинг R-6). `ValueError` из него ядро превращает в `RuntimeError` (500, агент повторит отчёт), чтобы не спутать с неверным статусом отчёта. @@ -152,9 +152,10 @@ 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 +## Выкатка и откат версии с новой схемой (v7, v8) - Порядок: `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, а хаб на прежнем коде (v6) с такой базой не работает (`agent_status`, токены). +- До 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`). - Откат на копию v6 из `backups/` (`*schema-v6*`): вместе с базой вернуть публикацию агента `release/jira/*` в `release/` (прежняя раскладка `release/agent.py`, `manifest.json`); иначе старый код увидит «публикации нет» и самообновление агента молчит. ## Открытые вопросы diff --git a/README.md b/README.md index 841a706..a012c99 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ uv run sdlc-hub serve # 0.0.0.0:8790 uv run sdlc-hub import ~/projects/awp-ca/admin/jira-sync ``` -Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON: +Переносит из CSV заметки (вид работы, что сделано, что осталось; колонку «Статус по факту» пропускает) и выводит отчёт JSON: `notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и `missing_links` (ожидаемые связи, которых нет), `reversed_links` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь). Запускать после первого `refresh` с `full: true`: пока зеркало пусто, все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются. @@ -213,10 +213,10 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py | `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` | | `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` | | `GET /projects`, `POST /projects`, `PATCH /projects/{метка}` | клиент | проекты `{projects: […]}`: метка, название, `created_at` и поля модулей — jira: `board_id`, `board_type`, `issues_count`, `jira_synced_at` (время последнего полного обновления из Jira; `null` — ещё не было), wiki: `wiki_root`, `wiki_synced_at`, `wiki_sync_error`. `POST` заводит `{label, name, поля модулей?, author?}` → 201 `{project, batch_id, batches}`: пакеты, поставленные модулями (`batches: [{module, id}]`; у jira — «Новый проект <метка>», `refresh` по общей JQL; `batch_id` — пакет первого модуля); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка отказана). `PATCH` меняет изменяемые поля модулей (иные — 422 `bad_project`) → 200 `{project, batch_ids, batches}` | -| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | +| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` | | `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) | | `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` | -| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | +| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон | | `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») | | `PUT /issues/{key}/note` | клиент | заметки (частично) | | `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) | @@ -245,9 +245,9 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub make web ``` -Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не -запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде -с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — +Порядок важен для версий с новой схемой (v7 — модуль wiki, v8 — без статуса по факту): сразу после `git pull` — kickstart, +потом `make web`. До kickstart не запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и +переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` — вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента. Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно diff --git a/docs/superpowers/plans/2026-10-24-fact-status-removal.md b/docs/superpowers/plans/2026-10-24-fact-status-removal.md new file mode 100644 index 0000000..4b54c41 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-fact-status-removal.md @@ -0,0 +1,202 @@ +# Удаление «статуса по факту» — план реализации + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** в хабе нет поля `issue_note.fact_status`, настроек `status_labels`/`transitions_map` и параметров статуса по +факту; живая база v7 мигрирует в v8 при старте. + +**Architecture:** сначала из кода модуля jira уходят все чтения и записи поля (колонка ещё есть, набор зелёный), затем +схема v8 и миграция удаляют колонку и ключи настроек, последним — веб-сидер, фикстуры и документация. + +**Tech Stack:** Python 3.13 (uv), SQLite 3.50, pytest; React/TS (Vitest, Playwright) — только фикстуры и сидер. + +**Spec:** `openspec/changes/fact-status-removal/` (proposal.md, design.md, specs/) + +## Global Constraints + +- Язык комментариев, docstring, сообщений коммитов — русский; идентификаторы — английские. +- `test_core_has_no_jira_terms`: в `src/sdlc_hub/core/` не появляются `fact_status`, `status_labels`, `transitions_map` + — шаг миграции живёт в модуле jira, ядро вызывает его ленивым импортом (design #1). +- Сообщение об ошибке заметки — существующее «неизвестные поля заметки: …» (`mirror.set_note`), новое не вводится. +- Исторические фикстуры `tests/fixtures/{settings_v6,schema_v6,config.example}.json`, планы `docs/superpowers/plans/*`, + архив `openspec/changes/archive/` не правятся (design #8, #10). +- Golden-эталоны доски `web/src/modules/jira/board/__fixtures__/*.expected.json` не меняются (design #7). +- Эталоны `tests/fixtures/baseline/*.expected.json` перезаписываются только `BASELINE_WRITE=1`, diff просматривается. +- Зелёный — только `make check` целиком. + +## Review Focus + +1. Миграция живой базы v7 с заметками без `fact_status` и с ним: прочие поля и `updated_at` целы — тест в Task 2. +2. База v7 без строки `settings_version` (или без ключей `status_labels`/`transitions_map`): миграция не падает, + версию не создаёт и не поднимает — тест в Task 2. +3. Повторный `db.connect` к базе v8 не трогает `settings_version` и не возвращает ключи — тест в Task 2. +4. `PUT /issues/{key}/note` с `fact_status` и другим полем вместе: 422, второе поле тоже не записано — тест в Task 1. +5. Сидер тестового хаба (`web/scripts/seed-hub.py`) на v8: e2e поднимается — проверка в Task 3. + +--- + +### Task 1: Код модуля jira без статуса по факту + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mirror.py` (`NOTE_FIELDS` без `fact_status`; `_FILTERS` без `fact_status`; выборки + `list_issues` и `board_snapshot` без `n.fact_status`; docstring `board_snapshot`) +- Modify: `src/sdlc_hub/modules/jira/api.py:19` (`ISSUE_FILTERS` без `fact_status`) +- Modify: `src/sdlc_hub/modules/jira/mcp.py` (`fmt_issues` — шапка «ключ | тема | статус | срок | исполнитель | + спринт», без последней колонки; `issues` и `notes_set` без параметра `fact_status`, docstring `issues` без «статус по + факту»; кортежи полей заметок в `issue` и `notes_set` без `fact_status`) +- Modify: `src/sdlc_hub/modules/jira/importer.py:45,59` (без ключа `fact_status`) +- Test: `tests/test_mirror.py`, `tests/test_queue.py`, `tests/test_api.py`, `tests/test_mcp.py`, `tests/test_importer.py`, + `tests/test_e2e_hub.py`; эталон `tests/fixtures/baseline/mcp_tools.expected.json` + +**Interfaces:** +- Produces: `mirror.NOTE_FIELDS == ["work_kind", "done_text", "rest_text", "owner_note"]` — Task 2 опирается на то, что + код колонку не читает и не пишет. + +- [ ] **Step 1: Переписать тесты на новое поведение** + +Во всех перечисленных тестах, где `fact_status` служит просто «каким-то полем заметки» (`test_mirror` 63–127, +`test_queue` 104–114, 365–410, `test_e2e_hub` 30), заменить его на `work_kind`/`rest_text` с теми же проверками. Новые и +изменённые проверки: + +```python +# tests/test_api.py — вместо записи fact_status (строки 115–116) +def test_note_rejects_fact_status(client): # имя фикстуры клиента — как у соседних тестов файла + with pytest.raises(HubError) as e: # класс ошибки клиента — как в соседних тестах на 422 + client.put("/issues/A-1/note", {"fact_status": "В работе", "work_kind": "Аналитика", "updated_by": "owner"}) + assert e.value.status == 422 and "неизвестные поля заметки: fact_status" in str(e.value) + assert client.get("/issues/A-1")["note"] is None # и work_kind не записан + +def test_issues_ignores_fact_status_param(client): + # при заметке work_kind у A-1: фильтр fact_status не сужает выдачу + assert [i["key"] for i in client.get("/issues", fact_status="В работе")["issues"]] == [<все ключи фикстуры>] + assert all("fact_status" not in i for i in client.get("/issues")["issues"]) +``` + +- `test_mirror.py:163–165`: множество полей `board_snapshot` без `"fact_status"`. +- `test_mcp.py:50–52`: `jmcp.notes_set("A-1", work_kind="Аналитика")`, в `issue` — «work_kind: Аналитика»; новый тест: + `"fact_status" not in inspect.signature(jmcp.notes_set).parameters` и то же для `jmcp.issues`; `fmt_issues` по строке + с `fact_status` выдаёт шапку без «статус по факту» и ровно 6 колонок (`test_mcp.py:79`). +- `test_importer.py:51`: `n["work_kind"] == "тестирование"` и `"fact_status" not in n`. + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run pytest -q tests/test_api.py tests/test_mcp.py tests/test_importer.py tests/test_mirror.py` +Expected: FAIL (`fact_status` принимается и выводится) + +- [ ] **Step 3: Убрать поле из `mirror`, `api`, `mcp`, `importer`** (см. Files) + +- [ ] **Step 4: Перезаписать эталон MCP и проверить diff** + +Run: `BASELINE_WRITE=1 uv run pytest -q tests/test_baseline.py::test_mcp_tools && git diff --stat tests/fixtures/baseline` +Expected: меняется только `mcp_tools.expected.json` (параметр `fact_status` у `issues` и `notes_set`, текст описания) + +- [ ] **Step 5: Весь набор хаба** + +Run: `uv run pytest -q` +Expected: всё зелёное + +- [ ] **Step 6: Commit** + +```bash +git add -A src tests && git commit -m "Модуль jira: заметки, API, MCP и импорт без статуса по факту (#104)" +``` + +### Task 2: Схема v8 и настройки без статуса по факту + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/schema.py:80` (без `fact_status TEXT`) +- Create function: `migrate_v7(conn) -> None` в `src/sdlc_hub/modules/jira/settings.py` (рядом с умолчаниями модуля): + `DROP COLUMN` при наличии колонки (`PRAGMA table_info(issue_note)`), `DELETE FROM setting WHERE key IN + ('status_labels', 'transitions_map')`; если удалена хотя бы одна строка и есть строка `settings_version` — `+1` +- Modify: `src/sdlc_hub/core/db.py` (`SCHEMA_VERSION = 8`; шаг `_note_fields_v8(conn)` с ленивым импортом + `from ..modules.jira import settings` → `settings.migrate_v7(conn)`; `MIGRATIONS[7] = [_note_fields_v8]` с + комментарием «v7 → v8: без статуса по факту (fact-status-removal)») +- Modify: `src/sdlc_hub/modules/jira/settings.py` (`DEFAULTS` и `AGENT_KEYS` без `status_labels`, `transitions_map`; + `init_from_config` их не переносит) +- Test: `tests/test_db.py`, `tests/test_settings.py`, `tests/test_agents.py`, `tests/test_cli.py`; эталоны + `settings.expected.json`, `schema.expected.json` + +**Interfaces:** +- Consumes: Task 1 — код колонку не использует. +- Produces: `db.SCHEMA_VERSION == 8`. + +- [ ] **Step 1: Переписать тесты** + +- Все `== "7"`/`== 7` про `schema_version` в `test_db.py` и `test_agents.py:25` → `str(db.SCHEMA_VERSION)` / + `db.SCHEMA_VERSION`. +- Построители старых баз (`_v1_database`, `test_migrates_v2_with_backup`, `_v3_database`) вставляют заметку после + `conn.execute("ALTER TABLE issue_note ADD COLUMN fact_status TEXT")` (так выглядела база тех версий) с + `fact_status = 'В работе', work_kind = 'Аналитика'`; проверки после миграции: колонки `fact_status` нет, + `work_kind == 'Аналитика'`. +- Новые тесты в `test_db.py` с построителем `_v7_database(path, settings_version=12, with_keys=True)`: `db.connect`, + `settings.ensure_defaults`, `ADD COLUMN fact_status`, заметка A-1 (`fact_status`, `work_kind`, `updated_at = + '2026-10-20T00:00:00+00:00'`), при `with_keys` — `INSERT OR REPLACE` ключей `status_labels`/`transitions_map`, + `settings_version`, `schema_version = 7`: + +```python +def test_migrates_v7_drops_fact_status(tmp_path): + # колонки нет; work_kind и updated_at A-1 прежние; ключей нет; settings_version == 13; копия *-schema-v7.sqlite одна +def test_migrates_v7_without_keys_keeps_settings_version(tmp_path): + # with_keys=False → settings_version == 12 +def test_migrates_v7_without_settings_version(tmp_path): + # строка settings_version удалена до миграции → после неё строки нет, ошибки нет +def test_v8_reconnect_is_stable(tmp_path): + # после миграции второй db.connect + ensure_defaults: settings_version тот же, ключей status_labels/transitions_map нет +``` + +- `test_settings.py:58`: `"transitions_map" not in keys and "status_labels" not in keys`, ключей нет в + `settings.get_all(conn)`, хотя в `config.example.json` они есть. +- `test_agents.py:82`: `"link_types" in jira`, `"transitions_map" not in jira and "status_labels" not in jira`. +- `test_cli.py:16`: `"link_types" in out`, `"transitions_map" not in out`. + +- [ ] **Step 2: Убедиться, что тесты падают** + +Run: `uv run pytest -q tests/test_db.py tests/test_settings.py tests/test_agents.py tests/test_cli.py` +Expected: FAIL (версия 7, ключи на месте) + +- [ ] **Step 3: Реализовать схему v8, `migrate_v7` и правку умолчаний** (см. Files) + +- [ ] **Step 4: Эталоны настроек и схемы** + +Run: `BASELINE_WRITE=1 uv run pytest -q tests/test_baseline.py::test_settings tests/test_baseline.py::test_schema && git diff tests/fixtures/baseline` +Expected: из `settings` ушли два ключа (умолчания и `agent_keys`), в `schema` — `schema_version` 8 и `issue_note` без +`fact_status`; других отличий нет + +- [ ] **Step 5: Весь набор хаба** + +Run: `uv run pytest -q` +Expected: всё зелёное + +- [ ] **Step 6: Commit** + +```bash +git add -A src tests && git commit -m "Схема v8: без статуса по факту и его настроек (#104)" +``` + +### Task 3: Веб-сидер, фикстуры, документация + +**Files:** +- Modify: `web/scripts/seed-hub.py:25,30` (заметка — только `work_kind`) +- Modify: `web/src/modules/jira/board/__fixtures__/hub-basic.board.json` (без `fact_status`), + `web/src/modules/jira/board/__fixtures__/settings.json` (без `status_labels`) +- Modify: `README.md:47,216,219`; `ARCHITECTURE.md` — если упоминает поле или ключи +- Без изменений: `web/src/boundary.test.ts` (`MODULE_TERMS`), `IssuesPage.test.tsx:239–245` (design #7) + +- [ ] **Step 1: Правка сидера, фикстур, README** (см. Files) + +- [ ] **Step 2: Проверки фронта и e2e** + +Run: `make check && (cd web && npm run e2e) && git status --short web/src/modules/jira/board/__fixtures__` +Expected: всё зелёное; среди изменённых фикстур нет `*.expected.json` + +- [ ] **Step 3: Остатки** + +Run: `grep -rn "fact_status\|status_labels\|transitions_map\|по факту" src web/src web/scripts README.md ARCHITECTURE.md` +Expected: только `boundary.test.ts`, `IssuesPage.test.tsx`, `IssueCard.test.tsx`, `card.ts` (комментарий об истории +«было — по статусу по факту») и строка `README` про CSV, если колонку там нужно назвать как пропускаемую + +- [ ] **Step 4: Commit** + +```bash +git add -A web README.md ARCHITECTURE.md && git commit -m "Веб-сидер, фикстуры и README без статуса по факту (#104)" +``` diff --git a/openspec/changes/fact-status-removal/.openspec.yaml b/openspec/changes/fact-status-removal/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/fact-status-removal/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/fact-status-removal/design.md b/openspec/changes/fact-status-removal/design.md new file mode 100644 index 0000000..7f1c1d0 --- /dev/null +++ b/openspec/changes/fact-status-removal/design.md @@ -0,0 +1,129 @@ +## Context + +Основа — `main` после sdlc-rename (#97) и web-modules (#98), `5a21825`. Схема базы — v7 (`core/db.py`, +`SCHEMA_VERSION = 7`). Issue #104, ветка `feat/104-fact-status-removal`. + +Где живёт статус по факту (разведка 24.10.2026): +- база: `issue_note.fact_status` (`modules/jira/schema.py`); `mirror.NOTE_FIELDS`, фильтр `fact_status` в + `mirror.list_issues`, колонка в выборках `list_issues` и `board_snapshot` (`modules/jira/mirror.py`); +- API: `ISSUE_FILTERS` (`modules/jira/api.py`); `PUT /issues/{key}/note` принимает поле через `NOTE_FIELDS`; +- MCP: `issues` (параметр и колонка таблицы), `notes_set` (параметр), `issue` (строка заметок) — `modules/jira/mcp.py`; +- настройки: `status_labels`, `transitions_map` в `DEFAULTS` и `AGENT_KEYS` (`modules/jira/settings.py`), перенос из + конфига в `init --config` (там же); +- импорт: колонка «Статус по факту» в `tasks.csv`/`new_tasks.csv` (`modules/jira/importer.py`); +- веб: типов с `fact_status` уже нет (#57); остались фикстуры (`board/__fixtures__/hub-basic.board.json`, + `settings.json`), тест-страж терминов (`boundary.test.ts`), тест «параметр из адреса не уходит в запрос» + (`IssuesPage.test.tsx`) и сидер тестового хаба `web/scripts/seed-hub.py` — он пишет `fact_status` в заметки + через `PUT /issues/{key}/note` и после change получит 422; +- тесты хаба и эталоны: `tests/fixtures/baseline/{mcp_tools,settings,schema}.expected.json`, `test_db`, `test_mirror`, + `test_api`, `test_mcp`, `test_importer`, `test_settings`, `test_e2e_hub`, `test_cli`, `test_queue`, `test_agents`; + `tests/fixtures/{settings_v6,schema_v6,config.example}.json` — входы миграционных тестов, исторические. + +jira-agent (`../jira-agent/src/agent.py`) ни `status_labels`, ни `transitions_map` не читает: `ensure_status` получает +путь статусов в параметре `target`. Потребители MCP в `~/projects` поле не упоминают. + +## Goals / Non-Goals + +**Goals:** в хабе нет ни поля, ни настроек, ни параметров статуса по факту; живая база v7 мигрирует при старте без +ручных шагов; прочие заметки целы. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Колонка удаляется, а не обнуляется** (решение владельца 24.10.2026: «удаляй безвозвратно»). Шаг миграции v7 → v8 + — `ALTER TABLE issue_note DROP COLUMN fact_status`, только если колонка есть (новая база v8 собирается схемой без + неё). Сам шаг живёт в модуле jira (функция в `modules/jira/`, ядро вызывает её ленивым импортом из `MIGRATIONS[7]`, + как `_project_fields_v7` вызывает `projects.migrate_v6`): термины jira в ядро не попадают (`test_core_has_no_jira_terms`). + SQLite хаба — 3.50 (Python 3.13 от uv), `DROP COLUMN` поддерживается с 3.35. Копию базы делает общий механизм + миграции (`backups/<дата>-schema-v7.sqlite`), отдельной выгрузки нет. +2. **Ключи `status_labels` и `transitions_map` удаляются из `setting` той же миграцией** и уходят из `DEFAULTS` и + `AGENT_KEYS`, иначе `ensure_defaults` создаст их снова. Если удалён хотя бы один ключ — `settings_version` + 1: + набор ключей агента изменился, агент перечитает настройки (на работу агента это не влияет). Строки + `settings_version` может не быть (база без `ensure_defaults`) — тогда версия не поднимается и не создаётся. +3. **`PUT /issues/{key}/note` с `fact_status` — 422** «неизвестные поля заметки: fact_status» — общее правило + `mirror.set_note` для полей вне `NOTE_FIELDS`, отдельного кода нет. +4. **`GET /issues?fact_status=…` — параметр игнорируется**, как любой неизвестный: `h_issues` читает только + `ISSUE_FILTERS`. Веб его уже не шлёт, а старые закладки с параметром не ломаются. +5. **`init --config` не переносит `status_labels` и `transitions`**, даже если они есть в конфиге. +6. **Импорт пропускает колонку «Статус по факту»**; остальные колонки — как раньше. +7. **Веб: только тесты, фикстуры и сидер.** `seed-hub.py` перестаёт писать `fact_status` в заметки. Из + `hub-basic.board.json` и `settings.json` поле и ключ убираются; golden-эталоны доски (`*.expected.json`) MUST NOT + измениться — доска поле не читает. `fact_status` в `MODULE_TERMS` теста-стража остаётся: ядро веба по-прежнему не + должно его содержать. Тест `IssuesPage.test.tsx` про параметр из адреса остаётся (поведение то же). +8. **Исторические фикстуры (`*_v6.json`, `config.example.json`) не правятся**: это входы миграций и `init`, на них + проверяется, что поле и ключи пропадают. +9. **Спека `data-model`: «текущая — 8»**, сценарии миграций v1–v5 вместо числа говорят «текущая» — число «6» в них + отстало от кода ещё в wiki-module (v7) и ломалось бы при каждой новой версии. + +10. **Документация и тексты в коде правятся вместе с кодом:** README (описание `import`, строки `GET /issues` и + `GET /board`), docstring `mirror.board`, `mcp.issues`, `mcp.notes_set`, обе строки чтения CSV в + `importer.py`. Исторические планы `docs/superpowers/plans/*.md` и архив `openspec/changes/archive/` не правятся; + итоговая проверка — `grep -rn "fact_status\|status_labels\|transitions_map\|по факту" src web/src web/scripts README.md + ARCHITECTURE.md openspec/specs` даёт только намеренные остатки (страж `MODULE_TERMS`, тест параметра из адреса, + текст спек «статуса по факту нет»). + +11. **Устаревшие ключи настроек объявляет модуль** (`Module.settings_obsolete`, у jira — `status_labels`, + `transitions_map`); `core.settings.ensure_defaults` удаляет их при каждом вызове, `settings_version` не поднимает + (агенту ключи не передаются). Ядро имён ключей не знает. + +## Справка: пути переходов из `transitions_map` + +Значение настройки на живом хабе 24.10.2026 — пути по workflow AURORA (статус Jira, в который переводили задачу, +по виду работы; `default` — для видов, которых нет в строке; «—» — пути нет). Ключи — статусы по факту, сами пути пригодятся для спринтов/бэклога. + +| Статус по факту | default | аналитика / решение / актуализация | организационная | +|---|---|---|---| +| Готово — закрыть | Разработка → Готово к тестированию | Разработка → Готово | Разработка → Готово | +| В работе | Разработка | Ожидает анализа → Анализ | Разработка (default) | +| Ожидает решения владельца/заказчика | Ожидает анализа | Ожидает анализа | Ожидает анализа | +| К актуализации аналитики | Ожидает анализа | Ожидает анализа | Ожидает анализа | +| Ожидание разработки | — | Ожидает анализа | — | +| Частично — переформулировать | — | Ожидает анализа | — | +| Снято | Снято | Снято | Снято | + +`status_labels` сопоставлял статусам по факту служебные метки `fact-*` (`fact-done`, `fact-partial`, +`fact-in-progress`, `fact-decision`, `fact-docs`, `fact-open`, `fact-roadmap`, `fact-cancelled`). + +## Risks / Trade-offs + +- **Значения статуса по факту теряются** — решение владельца; восстановить можно только из `backups/`. +- **Новый код до kickstart.** MCP-сервер и CLI открывают базу сами (`HubClient.from_home()` → `db.connect` + + `ensure_defaults`). Новая сессия Claude, `/mcp` → reconnect или `sdlc-hub tokens` между `git pull` и `kickstart` + мигрируют базу до v8, а хаб на прежнем коде отвечает 500 на `GET /issues`, `GET /board`, `PUT /issues/{key}/note` + (нет колонки). Порядок выкатки — `git pull` → сразу `kickstart` (README, ARCHITECTURE.md, раздел о выкатке схемы). +- **Старый код после kickstart.** Долгоживущие MCP-сессии со старым кодом при каждом вызове открывают базу и своим + `ensure_defaults` снова вставляют `status_labels`/`transitions_map` (`{}`); миграция уже прошла и не повторится, а + удалить ключ через API нельзя (I-1 финального ревью). Поэтому ключи объявлены устаревшими модулем + (`Module.settings_obsolete`), и `ensure_defaults` нового кода удаляет их при каждом вызове — при старте хаба и в каждой + новой MCP-сессии (design #11). Пока жива старая сессия, ключи могут появляться между её вызовами и вызовами нового кода; + агенту они не уходят (`AGENT_KEYS` нового кода). +- **Сессии MCP со старым кодом** до `/mcp` → reconnect показывают параметр `fact_status`; вызов с ним вернёт пустой + фильтр (хаб параметр игнорирует), `notes_set` с ним — 422. + +## Decisions made without discussion + +- 24.10.2026 — R-1 (design #2): `transitions_map` и `status_labels` удаляются вместе с полем; пути переходов сохранены + в разделе «Справка» этого документа. Обоснование: ключи карты — статусы по факту, читателей нет. Цена ошибки — + вернуть настройку из справки одной правкой `PUT /settings`. +- 24.10.2026 — R-2: jira-agent не меняется, design-only change в агенте не заводится (агент ключи не читает). Цена + ошибки — отдельный PR в агенте. +- 24.10.2026 — R-3 (design #2): `settings_version` поднимается, если миграция удалила ключ. Цена ошибки — один лишний + перечит настроек агентом. +- 24.10.2026 — R-4 (design #3, #4): запись поля в заметку — 422, фильтр в `GET /issues` — игнорируется. Обоснование: + общие правила хаба для неизвестных полей тела и параметров. Цена ошибки — правка одного обработчика. +- 24.10.2026 — R-5: M-3, M-4, M-5 из #60 в change не входят и остаются в #60. Цена ошибки — отдельная мелкая задача. +- 24.10.2026 — R-7 (design #11): после I-1 финального ревью — устаревшие ключи удаляет каждый `ensure_defaults`, без + подъёма `settings_version`. Обоснование: старые MCP-сессии возвращают ключи, а удалить их иначе нечем. Цена ошибки — + поле контракта модуля, которое придётся убрать. +- 24.10.2026 — R-6 (design #9): в `data-model` исправлено отставание номера версии схемы. Цена ошибки — нет. +- 24.10.2026 — R-8 (исполнение, Task 1): проверки «`fact_status` нет в заметке» в `test_api`/`test_importer` на время + Task 1 ослаблены до `.get(...) is None` (`get_note` читает `SELECT *`, колонка уходила в Task 2), в Task 2 возвращены. + Цена ошибки — нет. +- 24.10.2026 — R-9 (исполнение, Task 2): `test_v14_agent_over_http_on_v6_base` вычитает `status_labels` и + `transitions_map` из ключей агента исторического `settings_v6.json`; фикстура не правится (design #8). Цена ошибки — + одна строка теста. +- 24.10.2026 — R-10 (исполнение, Task 2): `migrate_v7` — в `modules/jira/settings.py`, ядро вызывает его ленивым + импортом (`_note_fields_v8`). Цена ошибки — перенос функции. +- 24.10.2026 — R-11 (финальное ревью): M-1 (ARCHITECTURE «v7») и M-4 (неточности proposal/design) исправлены вместе с I-2 + — те же документы. Цена ошибки — нет. diff --git a/openspec/changes/fact-status-removal/proposal.md b/openspec/changes/fact-status-removal/proposal.md new file mode 100644 index 0000000..60d3be6 --- /dev/null +++ b/openspec/changes/fact-status-removal/proposal.md @@ -0,0 +1,49 @@ +## Why + +«Статус по факту» (`issue_note.fact_status`) пришёл из CSV-пакета `admin/jira-sync`: владелец вёл его руками рядом со +статусом Jira, а `jira_sync.py` переводил задачи в Jira по таблице `transitions.map`, ключом которой был этот статус. +Владелец 17.10.2026 (brainstorming #57) решил, что поле — шум, и его нужно убрать совсем. Первым шагом (#57, PR #58) +поле убрали из веб-приложения; в базе, API, MCP, настройках и импорте оно осталось (#60). + +Хаб статус по факту не использует ни для чего, кроме хранения и показа: агент `ensure_status` получает путь статусов в +самой команде и `transitions_map`/`status_labels` не читает; потребители MCP (awp-ca, stocks_drrk, sfm_foxpro) поле не +упоминают. Владелец 24.10.2026: сохранённые значения удалить безвозвратно (#104). + +## What Changes + +- **База, схема v8:** миграция v7 → v8 удаляет колонку `issue_note.fact_status` и ключи `status_labels`, + `transitions_map` из `setting`, поднимает `settings_version`; копия базы — в `backups/` по общему правилу. +- **Настройки:** `status_labels` и `transitions_map` уходят из умолчаний модуля jira и из ключей для агента; + `sdlc-hub init --config` их не переносит; модуль объявляет их устаревшими (`Module.settings_obsolete`), и хаб удаляет их + при каждом `ensure_defaults`, если их вернул процесс прежней версии. +- **API:** `GET /issues` без фильтра и поля `fact_status` (параметр игнорируется, как любой неизвестный); + `GET /board` без `fact_status`; `PUT /issues/{key}/note` с `fact_status` — 422 «неизвестные поля заметки». +- **MCP `sdlc-jira`:** `issues` — без колонки «статус по факту» и параметра `fact_status`; `notes_set` — без параметра + `fact_status`; `issue` не выводит его в заметках. +- **Импорт** `sdlc-hub import` пропускает колонку «Статус по факту» CSV. +- **Веб:** типов с полем нет с #57; из фикстур уходят `fact_status` и `status_labels`, сидер тестового хаба не пишет + поле в заметки; поведение страниц не меняется. +- **Спеки и документация:** `data-model`, `hub-api`, `mcp`, `migration`, `modules`, `web-app`, README, ARCHITECTURE.md. + В `data-model` исправляется отставание: «текущая — 6» при коде v7. + +## Non-Goals + +- jira-agent: изменений не нужно, design-only change в агенте не заводится. +- `kind_labels`, `work_kind` и прочие заметки — остаются. +- Пункты M-3, M-4, M-5 из #60 (они про «скрыть закрытые», не про статус по факту) — остаются в #60. +- Спринты и бэклог модуля jira — следующий change. + +## Anti-Goals + +- Потеря других данных заметок (`work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`) при + миграции. +- Хаб, который после миграции снова создаёт `status_labels`/`transitions_map` (умолчания) или отдаёт их агенту. +- Падение хаба на базе v7 при старте: миграция должна пройти на живой базе без ручных шагов. + +## Impact + +- Код: `src/sdlc_hub/core/db.py` (v8), `src/sdlc_hub/modules/jira/{schema,settings,mirror,api,mcp,importer}.py`, + `web/src/modules/jira/**` (типы, фикстуры), тесты и эталоны `tests/fixtures/baseline/*`. +- Выкатка: `kickstart` хаба владельцем (миграция при старте), `/mcp` → reconnect в сессиях с `sdlc-jira`, `make web` + (типы веба). +- Закрывает пункт «Статус по факту — удалить совсем» в #60; issue #60 остаётся открытой (M-3, M-4, M-5). diff --git a/openspec/changes/fact-status-removal/specs/data-model/spec.md b/openspec/changes/fact-status-removal/specs/data-model/spec.md new file mode 100644 index 0000000..06e4d6d --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/data-model/spec.md @@ -0,0 +1,152 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`, +`attachment_file`, `issue_note`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 8). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени +Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. + +Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю +настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта +`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе +никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в +`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи +`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем +перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с +прежним значением. + +Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и +`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не +разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. + +Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений +появляются с первым `refresh` агента v14. + +Миграция v7 → v8 SHALL: удалить колонку `issue_note.fact_status` (если она есть; значения удаляются безвозвратно, копия — +в `backups/` по общему правилу) и ключи `status_labels` и `transitions_map` из `setting`, если они есть, и поднять +`settings_version`, если хотя бы один из ключей был удалён. Прочие поля заметок, задачи и настройки миграция MUST NOT +менять. + +#### Scenario: Первый запуск +- **WHEN** хаб стартует с несуществующим файлом базы +- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700` + +#### Scenario: Миграция базы v1 +- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed` +- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая + `issue_comment`; данные на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v3 +- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, + в `sync_run` полный прогон с этой `query` +- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, + пакеты на месте; `schema_version` = текущая + +#### Scenario: Миграция базы v4 +- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`, + в `sync_run` полный прогон +- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`, + `created` прежний; у прогона `history_at = NULL`; затем миграция v5 → v6; `schema_version` = текущая + +#### Scenario: Миграция базы v5 +- **WHEN** хаб стартует с базой `schema_version = 5` +- **THEN** в `backups/` копия базы v5; есть пустые `issue_attachment` и `attachment_file`; прочие таблицы не тронуты; + `schema_version` = текущая + +#### Scenario: Миграция базы v7 +- **WHEN** хаб стартует с базой `schema_version = 7`, у задачи X заметка `fact_status = "В работе"`, `work_kind = + "Аналитика"`, в `setting` есть `status_labels` и `transitions_map`, `settings_version = 12` +- **THEN** в `backups/` копия базы v7; в `issue_note` нет колонки `fact_status`, у X `work_kind = "Аналитика"`, + `updated_at` прежний; ключей `status_labels` и `transitions_map` в `setting` нет; `settings_version = 13`; + `schema_version = 8` + +#### Scenario: refresh в очереди при миграции +- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается + полным прогоном + +#### Scenario: Два соединения к базе v1 +- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения +- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1` + +#### Scenario: Открытый каталог данных +- **WHEN** хаб стартует, каталог данных существует с правами `0755` +- **THEN** права каталога и `backups/` — `0700` + +### Requirement: Служебные заметки +Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), +`work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала +MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL перенести заметку на настоящий +ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой или переведённой в `failed` до отчёта. Если +под настоящим ключом заметка уже есть, хаб SHALL слить их: каждое поле берётся из временной заметки, если там оно +непустое, иначе остаётся под настоящим ключом; `updated_at`/`updated_by` — от более поздней; временная строка удаляется. + +#### Scenario: Заметка переживает refresh +- **WHEN** у задачи есть `issue_note.work_kind = "Аналитика"` и приходит полное обновление зеркала +- **THEN** `work_kind` не изменился + +#### Scenario: Слияние с заметкой под ключом +- **WHEN** у `new:5` заметка `work_kind = "Аналитика"`, `owner_note` пусто; у `AURORA-1640` заметка `owner_note = "спросить ИБ"`; + `ensure_issue` для `new:5` завершилась с `AURORA-1640` +- **THEN** у `AURORA-1640` `work_kind = "Аналитика"` и `owner_note = "спросить ИБ"`; строки `new:5` нет + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование +«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме +передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции); +Ключи, которые модуль объявил устаревшими (у jira — `status_labels`, `transitions_map`), хаб SHALL удалять при каждом +заполнении умолчаний, не меняя `settings_version`. `GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `sdlc-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт + +#### Scenario: Запись базовой JQL +- **WHEN** `PUT /settings {base_jql: "project = AURORA"}` +- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился + +#### Scenario: Создание проекта не трогает версию настроек +- **WHEN** создан проект `STOCKS_DRRK` +- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками + +#### Scenario: Ключи вернул процесс прежней версии +- **WHEN** база v8, процесс со старым кодом (MCP-сессия до reconnect) вставил `status_labels = {}` и + `transitions_map = {}`, затем вызван `ensure_defaults` текущего кода (старт хаба, новая MCP-сессия) +- **THEN** ключей в `setting` нет, `settings_version` прежний + +#### Scenario: Ключей статуса по факту нет +- **WHEN** хаб открыл базу v8 +- **THEN** `GET /settings` не содержит `status_labels` и `transitions_map`; `GET /agent/settings` агенту `jira` их не + отдаёт diff --git a/openspec/changes/fact-status-removal/specs/hub-api/spec.md b/openspec/changes/fact-status-removal/specs/hub-api/spec.md new file mode 100644 index 0000000..05738d4 --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/hub-api/spec.md @@ -0,0 +1,143 @@ +## MODIFIED Requirements + +### Requirement: Маршруты интерфейсов +Хаб SHALL предоставлять интерфейсам (MCP, веб-приложение) следующие маршруты под клиентским токеном: +- `GET /issues` с фильтрами `project` (разрез по проекту, см. `data-model`, «Проекты»: свои и соседи, `neighbor` + относительно проекта; неизвестный проект — 404), `sprint`, `assignee`, `work_kind`, `status`, `label`, + `epic`, `neighbor`, `q` (подстрока темы), `hide_closed` (`1` / `true` — без задач, чей `status` входит в настройку + `closed_statuses`; задача без статуса не закрыта), `limit`/`offset` — строки зеркала (все поля `issue`) с `work_kind` из + заметок; параметр `fact_status` хаб игнорирует, как любой неизвестный; сокращение до карточки — дело интерфейса. Без `project` — всё зеркало, `neighbor` — признак + зеркала (вне базовой JQL). +- `GET /issues/{key}` — задача целиком: поля, `epic_summary` (тема эпика из зеркала или `null`), связи в обе стороны + (`links_out: [{target_key, link_type, summary, status}]`, `links_in: [{source_key, link_type, summary, status}]`, где + `summary` и `status` — связанной задачи из зеркала или `null`, если её там нет), заметки, последние 10 команд по ней (без `fetch_attachment`), + `comments: [{comment_id, author, created, updated, body, attachment_ids}]` — все комментарии, новые сверху (по `created`, затем + `comment_id`, по убыванию); `attachments: [{attachment_id, filename, size, mime_type, author, created, state, error}]` — + все вложения задачи, новые сверху (по `created_at`, затем `attachment_id`, по убыванию), `state` и `error` — см. + `data-model`, «Скачанные вложения» (`error` — только при `failed`, иначе `null`); `description_attachment_ids` и + `attachment_ids` комментария — id вложений, на которые ссылается текст (см. «Ссылки на вложения в тексте»), в порядке + первого упоминания, без повторов. +- `PUT /issues/{key}/note` — заметки (частичное обновление, `updated_by` из тела); поле вне списка заметок (в том числе + `fact_status`) — 422 «неизвестные поля заметки: …», заметка не меняется. +- `POST /batches` `{title, author, project?, commands: [...]}` → `{id, estimate_seconds}`; 422 при неизвестном типе, + неверных параметрах (в том числе неверного типа значения), временном ключе без `ensure_issue`, двух и более + `ensure_issue` с одинаковой `summary` (сравнение после обрезки пробелов по краям; в ответе перечислены повторяющиеся + темы), по проверкам постановки из `commands` (тип `bug` без настройки, файл вложения не загружен, доска для + `ensure_sprint`), а также по проверкам пакета проекта (см. `commands`), если передан `project`. +- `GET /attachments/{id}`, `POST /attachments/{id}/fetch`, `GET /attachments/{id}/content` — вложения задач (см. + «Скачивание вложений»). +- `POST /files` `{content}` (`content` — base64 содержимого файла) → `{file, size}`, где `file` — sha256 содержимого + (64 hex-символа, нижний регистр); см. «Хранилище файлов вложений». +- `GET /batches?limit=&project=` (с `project` — только пакеты этого проекта), `GET /batches/{id}` (с командами и + `project`), `POST /batches/{id}/cancel` — отменяет `queued` команды пакета; ответ — вид пакета с полем `still_claimed`: + список `{id, type}` команд пакета в `claimed` (они будут выполнены агентом; остановить — отмена команды с `force`). +- `POST /commands/{id}/retry` `{author?, project?}` — новая команда с теми же параметрами в новом пакете «Повтор + команды N»; хаб снимает с параметров то, что дописывает сам (`board_id` у `ensure_sprint`), и проверяет команду + заново. `project` передан и у исходного пакета другой проект (или его нет) — 422 «команда #N не из проекта P»; не + передан — новый пакет получает проект исходного и проходит его проверки. +- `POST /commands/{id}/cancel` — только для `queued`; для `claimed` — 409, если не передан `force: true`. +- `POST /refresh` `{keys | jql | full: true}` → пакет из одного `refresh`; `full` — по базовой JQL из проектов, без + проектов — 422 «нет проектов». +- `GET /agent`, `GET /sync-runs?limit=`, `GET /settings`, `PUT /settings` (частично; поднимает `settings_version`; + ответ — все настройки после записи, как у `GET /settings`, включая `settings_version`). + +#### Scenario: Фильтр по спринту +- **WHEN** `GET /issues?sprint=Спринт 2` +- **THEN** только задачи с таким `sprint`, отсортированы по сроку, затем по ключу + +#### Scenario: Список проекта +- **WHEN** свои задачи `STOCKS_DRRK` — AURORA-1 и AURORA-4, сосед — AURORA-2; `GET /issues?project=STOCKS_DRRK` +- **THEN** три задачи, у AURORA-2 `neighbor = true`; с `neighbor=1` — только AURORA-2; задач только с меткой `AWPCA` нет + +#### Scenario: Неизвестный проект +- **WHEN** `GET /issues?project=NOPE` +- **THEN** 404 + +#### Scenario: Пакеты проекта +- **WHEN** пакет 10 поставлен с `project = STOCKS_DRRK`, пакет 11 — без проекта; `GET /batches?project=STOCKS_DRRK` +- **THEN** в ответе только пакет 10 + +#### Scenario: Повтор чужой команды +- **WHEN** команда 40 из пакета проекта `AWPCA`; `POST /commands/40/retry {project: "STOCKS_DRRK"}` +- **THEN** 422 «команда #40 не из проекта STOCKS_DRRK», пакет не создан + +#### Scenario: Повтор ensure_sprint +- **WHEN** `ensure_sprint` 41 failed, в её `params` `board_id = 4216`; `POST /commands/41/retry` +- **THEN** новый пакет создан, `board_id` в новой команде вычислен хабом заново + +#### Scenario: Полный прогон без проектов +- **WHEN** проектов нет, `POST /refresh {full: true}` +- **THEN** 422 «нет проектов», пакет не поставлен + +#### Scenario: Отмена взятой команды +- **WHEN** `POST /commands/15/cancel` без `force`, команда `claimed` +- **THEN** 409 с `claimed_at`; с `force: true` — `cancelled`, следующий отчёт агента по ней принимается, статус не меняется + +#### Scenario: Поздний отчёт по отменённому созданию +- **WHEN** `ensure_issue {temp_key: new:7, …}` отменена с `force: true`, затем агент прислал `done` с `result.key = AURORA-1620` +- **THEN** команда `cancelled`, в `result` — `AURORA-1620`; заметка `new:7` перенесена на `AURORA-1620` + +#### Scenario: Дубль темы в пакете +- **WHEN** `POST /batches` с двумя `ensure_issue`, у обеих `summary = "Шахматка: комментарии"` +- **THEN** 422 с темой «Шахматка: комментарии», пакет не создан + +#### Scenario: Тема не строка +- **WHEN** `POST /batches` с `ensure_issue {temp_key: new:1, summary: 42, …}` +- **THEN** 422 с указанием параметра `summary`, пакет не создан + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** в пакете 9 команда 30 `claimed`, команды 31 и 32 `queued`; `POST /batches/9/cancel` +- **THEN** 31 и 32 `cancelled`; в ответе `still_claimed = [{id: 30, type: …}]`; 30 остаётся `claimed` + +#### Scenario: Ответ на запись настроек +- **WHEN** `PUT /settings {relay_dir: "/Volumes/externalSSD/utm/rshb/jira-bridge"}` +- **THEN** 200; ответ содержит `relay_dir` с записанным значением, новую `settings_version` и остальные настройки; + токенов в ответе нет + +#### Scenario: Карточка с комментариями и связями +- **WHEN** у AURORA-9001 два комментария (14.10 и 15.10), исходящая связь на AURORA-9002 («API свода», «Новая») и на + AURORA-1, которой нет в зеркале; эпик AURORA-9100 «Тестовый эпик» +- **THEN** `GET /issues/AURORA-9001` отдаёт `comments` с комментарием от 15.10 первым, у связи на AURORA-9002 — + `summary = "API свода"`, `status = "Новая"`, у связи на AURORA-1 — `summary = null`, `status = null`; + `epic_summary = "Тестовый эпик"` + +#### Scenario: Без закрытых +- **WHEN** `closed_statuses = ["Готово", "Снято"]`, в зеркале задачи в статусах «В работе», «Готово», «Снято» и без + статуса; `GET /issues?hide_closed=1` +- **THEN** в ответе задачи «В работе» и без статуса; без `hide_closed` — все четыре + +### Requirement: Снимок доски +Хаб SHALL предоставлять `GET /board?project=` под клиентским токеном — согласованный снимок зеркала для доски связей, +прочитанный в одной транзакции: +- `generated_at` — момент снимка (UTC); +- `last_sync` — последний завершённый полный прогон (`refresh` по текущей базовой JQL из проектов): + `{started_at, finished_at, issues_count}` или `null`, если такого не было; общий для всех проектов; +- `issues` — с `project` — свои задачи проекта и его соседи (см. `data-model`, «Проекты»), `neighbor` относительно + проекта; без `project` — все задачи зеркала, включая эпики и соседей, `neighbor` — признак зеркала. Поля: `key`, + `summary`, `issue_type`, `status`, `priority`, `assignee`, `sprint`, `duedate`, `planned_start`, `planned_end`, + `epic_key`, `labels` (список), `neighbor` (bool), `work_kind` (из заметки, `null` если заметки нет); + порядок — по ключу; +- `links` — с `project` — связи, у которых хотя бы один конец — своя задача проекта; без `project` — все связи + зеркала: `{source, target, link_type}`, `link_type` — имя типа в Jira, направление как в `issue_link` (`source` + блокирует/ссылается на `target`). +Неизвестный проект — 404. Перевод в модель доски (вид работы по меткам, имена эпиков, логические типы связей) — дело +интерфейса; хаб отдаёт данные зеркала без преобразований. + +#### Scenario: Снимок с задачами и связью +- **WHEN** в зеркале эпик AURORA-1351, задачи AURORA-1620 (метка `kind-dev`, эпик AURORA-1351) и AURORA-1621, связь + `AURORA-1620 → AURORA-1621` типа `ASUPR_Блокер_Начала`, заметка `work_kind = "разработка"` у AURORA-1620 +- **THEN** `GET /board` отдаёт три задачи по ключу, у AURORA-1620 `labels = ["kind-dev"]`, `epic_key = "AURORA-1351"`, + `work_kind = "разработка"`; в `links` — `{source: "AURORA-1620", target: "AURORA-1621", link_type: "ASUPR_Блокер_Начала"}` + +#### Scenario: Снимок проекта +- **WHEN** AURORA-1 (метка `STOCKS_DRRK`) связана с AURORA-2 (метка `AWPCA`), AURORA-2 связана с AURORA-3 (метка `AWPCA`); + `GET /board?project=STOCKS_DRRK` +- **THEN** `issues` — AURORA-1 (`neighbor = false`) и AURORA-2 (`neighbor = true`); `links` — только AURORA-1 ↔ AURORA-2 + +#### Scenario: Последний полный прогон +- **WHEN** в журнале синхронизаций полный прогон, завершённый в 10:00, и после него обновление по ключам в 11:00 +- **THEN** `last_sync.finished_at` — 10:00 + +#### Scenario: Без токена +- **WHEN** `GET /board` без токена или с агентским токеном +- **THEN** 401 diff --git a/openspec/changes/fact-status-removal/specs/mcp/spec.md b/openspec/changes/fact-status-removal/specs/mcp/spec.md new file mode 100644 index 0000000..2611771 --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/mcp/spec.md @@ -0,0 +1,95 @@ +## MODIFIED Requirements + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: # <имя>, …»; затем вложения (см. «Вложения в MCP») | +| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «# <имя> (<размер>)» | +| `attachment` | `key`, `attachment` (номер вложения «#» или «», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"sdlc-jira": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "sdlc-hub", "mcp", "jira", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» + +#### Scenario: Комментарий целиком +- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}` +- **THEN** ответ — «AURORA-9001 #101 <автор>, создан » и полный текст комментария без обрезки, переносы строк сохранены + +#### Scenario: Нет такого комментария +- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999 +- **THEN** ответ «у AURORA-9001 нет комментария #999» diff --git a/openspec/changes/fact-status-removal/specs/migration/spec.md b/openspec/changes/fact-status-removal/specs/migration/spec.md new file mode 100644 index 0000000..0fd9696 --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/migration/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: Инициализация настроек +`sdlc-hub init --config ` SHALL перенести в `setting`: `project_key`, типы задач, +`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `kind_labels`, `comment_marker`, +`pacing` (в новую форму по типам команд), `link_types`; сгенерировать два токена; завести проект +`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`, +`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента); `status_labels` и `transitions` MUST NOT +переноситься (статуса по факту в хабе нет). + +#### Scenario: Повторный init +- **WHEN** `init` вызван на уже инициализированной базе +- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется + +#### Scenario: Первый init +- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216` +- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql`, `board_id`, `status_labels` + и `transitions_map` + +### Requirement: Импорт CSV-пакета +`sdlc-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить +`issue_note` по ключу Jira: вид работы, что сделано, что осталось, внутренние колонки; колонку «Статус по факту» +импорт SHALL пропускать. Строки +`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа +SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала +после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как +`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор +обновляет заметки, не дублирует. + +#### Scenario: Импорт и первая синхронизация +- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён +- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи + из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону + +#### Scenario: Связь в обратную сторону +- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные» + 1446→1445 +- **THEN** связь в `reversed_links`, в `missing_links` её нет + +#### Scenario: Эпик без ключа +- **WHEN** в `epics.csv` строка без значения в колонке «Ключ» +- **THEN** строка пропущена, заметка `new:*` для неё не создаётся diff --git a/openspec/changes/fact-status-removal/specs/modules/spec.md b/openspec/changes/fact-status-removal/specs/modules/spec.md new file mode 100644 index 0000000..93dbf14 --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/modules/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Агенты модулей +У каждого подключённого модуля, объявляющего типы команд, SHALL быть свой агент; имя агента — имя модуля (`jira` — +jira-agent, `wiki` — eva-agent). Агент SHALL определяться по токену: `setting.tokens = {client, agents: {<модуль>: +<токен>}}`, недостающий токен агента хаб создаёт при старте. Хаб SHALL для каждого агента отдельно: +- выдавать в `claim` только команды типов его модуля, с одной командой в полёте на агента (агенты работают + параллельно); +- хранить строку `agent_status` (ключ — имя агента); +- держать опубликованный релиз `release/<модуль>/` и состояние самообновления; +- отдавать в `GET /agent/settings` ключи ядра и ключи для агента только своего модуля; +- пускать на маршруты модуля со scope `agent` только агента этого модуля (токен другого агента — 401). + +Миграция v7 SHALL сохранить прежний агентский токен как токен агента `jira`, перенести строку `agent_status` в агента +`jira` и прежнюю публикацию `release/agent.py` в `release/jira/`. jira-agent v14 SHALL работать без обновления. + +#### Scenario: jira-agent после выкатки +- **WHEN** хаб v6 с агентским токеном T и публикацией v14 обновлён до v7, jira-agent v14 шлёт `claim` с токеном T +- **THEN** 200; ответ содержит `agent_release {version: 14, …}`; `GET /agent` показывает агента `jira` с версией 14 + +#### Scenario: Агенты не берут чужие команды +- **WHEN** в очереди пакет 1 с `ensure_issue` и пакет 2 с `wiki_sync`, оба `queued`; агент `wiki` шлёт `claim` +- **THEN** выдан `wiki_sync`; следующий `claim` агента `jira` получает `ensure_issue`, хотя `wiki_sync` ещё `claimed` + +#### Scenario: Чужой маршрут агента +- **WHEN** токеном агента `wiki` вызван `POST /agent/state` +- **THEN** 401, зеркало не меняется + +#### Scenario: Настройки только своего модуля +- **WHEN** токеном агента `wiki` вызван `GET /agent/settings` +- **THEN** в ответе есть `wiki_pacing` и `settings_version`, ключей jira (`project_key`, `link_types`) нет diff --git a/openspec/changes/fact-status-removal/specs/web-app/spec.md b/openspec/changes/fact-status-removal/specs/web-app/spec.md new file mode 100644 index 0000000..c11dd26 --- /dev/null +++ b/openspec/changes/fact-status-removal/specs/web-app/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: Служебные заметки +Карточка задачи SHALL показывать служебные заметки в режиме чтения: «Вид работы», «Сделано», +«Осталось», «Заметка владельца» (пустые — «—») и время и автор последнего изменения; кнопка «Править» SHALL открывать +форму: «вид работы» — выбор из значений `kind_labels` настроек (и текущего значения, если его там нет, и пустого), +«сделано», «осталось», «заметка владельца» — +многострочный текст; «Отмена» SHALL возвращать режим чтения, при несохранённых правках — после подтверждения. +Сохранение SHALL отправлять `PUT /issues/{key}/note` только с изменёнными полями и `updated_by: "web"`; очищенное поле +отправляется как `null`. После успеха SHALL показываться режим чтения с сохранённой заметкой; список задач под окном SHALL +показывать новый «вид работы» без «Перечитать», доска под окном SHALL перечитать снимок +(`GET /board`) без потери раскладки и прокрутки; при 422 — текст ошибки у формы без +потери введённого. Закрытие окна, переход по связи и уход в другой раздел с несохранёнными правками SHALL требовать +подтверждения. Статуса по факту в заметках нет. Заметки пишутся только в хаб, в Jira приложение ничего не отправляет. + +#### Scenario: Только изменённые поля +- **WHEN** в заметке нажато «Править», изменено только «осталось» и нажато «Сохранить» +- **THEN** тело `PUT /issues/{key}/note` — `{rest_text: "…", updated_by: "web"}`; карточка снова в режиме чтения с новым + «Осталось» + +#### Scenario: Уход с несохранёнными правками +- **WHEN** в форме заметки есть несохранённая правка и нажата Esc +- **THEN** приложение спрашивает подтверждение; при отказе окно открыто, форма с введённым текстом + +#### Scenario: Заметка видна на доске +- **WHEN** карточка AURORA-9002 открыта с доски, вид работы изменён на «тестирование» и сохранён +- **THEN** доска под окном остаётся на месте и перезапрашивается (`GET /board` отдаёт `work_kind` «тестирование»); окно закрывается без потери раскладки и прокрутки доски diff --git a/src/sdlc_hub/core/db.py b/src/sdlc_hub/core/db.py index 6b064d0..45a88fc 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 = 7 +SCHEMA_VERSION = 8 CORE_SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -67,6 +67,11 @@ def _project_fields_v7(conn) -> None: projects.migrate_v6(conn) +def _note_fields_v8(conn) -> None: + from ..modules.jira import settings + settings.migrate_v7(conn) + + def _fill_comment_utc(conn) -> None: from ..modules.jira import history history.fill_comment_utc(conn) @@ -130,6 +135,7 @@ MIGRATIONS = { 5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно 6: [_tokens_v7, _agent_status_v7, _project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15) + 7: [_note_fields_v8], # v7 → v8: без статуса по факту и его настроек (fact-status-removal) } diff --git a/src/sdlc_hub/core/modules.py b/src/sdlc_hub/core/modules.py index 3f447ac..e26f1c7 100644 --- a/src/sdlc_hub/core/modules.py +++ b/src/sdlc_hub/core/modules.py @@ -50,6 +50,7 @@ class Module: name: str schema: str = "" settings_defaults: dict = field(default_factory=dict) + settings_obsolete: tuple[str, ...] = () # ключи прежних версий: ensure_defaults их удаляет agent_keys: tuple[str, ...] = () agent_min_version_key: str | None = None # ключ настроек с минимальной версией агента модуля validate_setting: Callable[[str, object], bool] | None = None # True — ключ модуля проверен (ValueError — плохо) diff --git a/src/sdlc_hub/core/settings.py b/src/sdlc_hub/core/settings.py index 54bebc6..8165d9c 100644 --- a/src/sdlc_hub/core/settings.py +++ b/src/sdlc_hub/core/settings.py @@ -68,6 +68,9 @@ def validate(key, value) -> None: def ensure_defaults(conn) -> None: for key, value in defaults().items(): conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value))) + for m in registry.modules(): # ключи, которые процесс прежней версии мог вернуть своими умолчаниями после миграции + for key in m.settings_obsolete: + conn.execute("DELETE FROM setting WHERE key = ?", (key,)) tokens = get(conn, "tokens") or {} changed = False if "client" not in tokens: diff --git a/src/sdlc_hub/modules/jira/__init__.py b/src/sdlc_hub/modules/jira/__init__.py index 03f1bef..606efe5 100644 --- a/src/sdlc_hub/modules/jira/__init__.py +++ b/src/sdlc_hub/modules/jira/__init__.py @@ -13,6 +13,7 @@ MODULE = Module( name="jira", schema=schema.SQL, settings_defaults=settings.DEFAULTS, + settings_obsolete=settings.OBSOLETE, agent_keys=tuple(settings.AGENT_KEYS), agent_min_version_key="min_agent_version", validate_setting=settings.validate_setting, diff --git a/src/sdlc_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py index 0ac3983..c001c6d 100644 --- a/src/sdlc_hub/modules/jira/api.py +++ b/src/sdlc_hub/modules/jira/api.py @@ -16,7 +16,7 @@ log = logging.getLogger(__name__) MIME_RE = re.compile(r"[\w!#$&^.+-]+/[\w!#$&^.+-]+", re.ASCII) # тип/подтип без параметров, пробелов и не-ASCII: заголовок не внушить DOWNLOADS_EVERY = 24 * 3600 # уборка downloads/ не чаще раза в сутки -ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "fact_status", "status", "label", "epic", "neighbor", "q", "hide_closed", "project") +ISSUE_FILTERS = ("sprint", "assignee", "work_kind", "status", "label", "epic", "neighbor", "q", "hide_closed", "project") _OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") diff --git a/src/sdlc_hub/modules/jira/importer.py b/src/sdlc_hub/modules/jira/importer.py index a94bc07..0764a48 100644 --- a/src/sdlc_hub/modules/jira/importer.py +++ b/src/sdlc_hub/modules/jira/importer.py @@ -42,7 +42,7 @@ def _import(conn, folder: Path) -> dict: if not key: continue _note(conn, key, { - "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "work_kind": _clean(r.get("Вид работы")), "done_text": _clean(r.get("Что сделано по факту (внутр.)")), "rest_text": _clean(r.get("Что осталось (внутр.)")), "owner_note": _clean(r.get("Комментарий актуализации"))}) notes += 1 @@ -56,7 +56,7 @@ def _import(conn, folder: Path) -> dict: new_keys[f"new:{i}"] = real csv_keys.append(real) _note(conn, key, { - "fact_status": _clean(r.get("Статус по факту")), "work_kind": _clean(r.get("Вид работы")), + "work_kind": _clean(r.get("Вид работы")), "rest_text": _clean(r.get("Основание (внутр.)")), "owner_note": None if real else _clean(r.get("Тема"))}) notes += 1 diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index ca51fc1..c07bb17 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -26,10 +26,10 @@ fmt_agent = core.fmt_agent def fmt_issues(issues) -> str: if not issues: return "задач нет" - lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] + lines = ["ключ | тема | статус | срок | исполнитель | спринт"] for i in issues: lines.append(" | ".join([i["key"], core.cut(i.get("summary")), core.value(i.get("status")), core.value(i.get("duedate")), - core.value(i.get("assignee")), core.value(i.get("sprint")), core.value(i.get("fact_status"))])) + core.value(i.get("assignee")), core.value(i.get("sprint"))])) return "\n".join(lines) @@ -73,7 +73,7 @@ def fmt_issue(d) -> str: links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] note = d.get("note") or {} - notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] + notes = [f" {k}: {note[k]}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] comments = d.get("comments") or [] if comments: @@ -180,11 +180,11 @@ def fmt_batch(b) -> str: @mcp.tool() @core.guard @core.scoped(True) -def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", +def issues(sprint: str = "", assignee: str = "", work_kind: str = "", status: str = "", label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: - """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, + """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус Jira, метка, эпик, подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" - data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + data = core.client().get("/issues", project=core.PROJECT, sprint=sprint, assignee=assignee, work_kind=work_kind, status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) return fmt_issues(data["issues"]) @@ -297,17 +297,17 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: @mcp.tool() @core.guard @core.scoped(True) -def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, +def notes_set(key: str, work_kind: str | None = None, done_text: str | None = None, rest_text: str | None = None, owner_note: str | None = None) -> str: """Правка служебных заметок задачи (только переданные поля).""" - fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, + fields = {k: v for k, v in dict(work_kind=work_kind, done_text=done_text, rest_text=rest_text, owner_note=owner_note).items() if v is not None} fields["updated_by"] = core.AUTHOR if not is_temp(key): # новая задача пакета проекта — своя по построению if core.PROJECT not in (core.client().get(f"/issues/{key}").get("labels") or []): return f"{key} не в проекте {core.PROJECT}" note = core.client().put(f"/issues/{key}/note", fields) - return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) + return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) def _read_local(path) -> bytes | str: diff --git a/src/sdlc_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py index b90912c..be3cd08 100644 --- a/src/sdlc_hub/modules/jira/mirror.py +++ b/src/sdlc_hub/modules/jira/mirror.py @@ -7,7 +7,7 @@ from ...core import db ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] -NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] +NOTE_FIELDS = ["work_kind", "done_text", "rest_text", "owner_note"] def check_comments(issues) -> None: """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" @@ -54,7 +54,7 @@ def check_attachments(issues) -> None: _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", - "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} + "work_kind": "n.work_kind = ?"} def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authoritative=True) -> dict: @@ -155,11 +155,11 @@ def list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=()) -> l where.append("(i.summary LIKE ? OR i.key LIKE ?)") args += [f"%{filters['q']}%", f"%{filters['q']}%"] if project: - sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.fact_status, n.work_kind FROM issue i " + sql = (SCOPE_CTE + "SELECT i.*, s.nb AS scope_nb, n.work_kind FROM issue i " "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key") args = [project] + args else: - sql = "SELECT i.*, n.fact_status, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" + sql = "SELECT i.*, n.work_kind FROM issue i LEFT JOIN issue_note n ON n.key = i.key" sql = (sql + (" WHERE " + " AND ".join(where) if where else "") + " ORDER BY i.duedate IS NULL, i.duedate, i.key LIMIT ? OFFSET ?") @@ -223,7 +223,7 @@ BOARD_FIELDS = ["key", "summary", "issue_type", "status", "priority", "assignee" def board(conn, base_jql, project: str | None = None) -> dict: - """Снимок зеркала для доски связей (GET /board): все задачи с заметками fact_status/work_kind, все связи и последний + """Снимок зеркала для доски связей (GET /board): все задачи с work_kind из заметок, все связи и последний успешный полный прогон (refresh по базовой JQL, команда done). Данные без преобразований — перевод в модель доски делает интерфейс. С project — свои задачи проекта и соседи (neighbor относительно проекта), связи — те, у которых source или target — своя задача; last_sync — как без проекта. Чтение в одной транзакции: задачи, связи и last_sync — из одного состояния базы.""" @@ -234,7 +234,7 @@ def board(conn, base_jql, project: str | None = None) -> dict: if project: cols = ", ".join("i." + c for c in BOARD_FIELDS if c != "neighbor") issues = [_issue_dict(r) for r in conn.execute( - SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.fact_status, n.work_kind FROM issue i " + SCOPE_CTE + f"SELECT {cols}, s.nb AS neighbor, n.work_kind FROM issue i " "JOIN scope s ON s.key = i.key LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key", (project,))] links = [dict(r) for r in conn.execute( SCOPE_CTE + "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " @@ -242,7 +242,7 @@ def board(conn, base_jql, project: str | None = None) -> dict: "ORDER BY source_key, target_key, link_type", (project,))] else: issues = [_issue_dict(r) for r in conn.execute( - f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.fact_status, n.work_kind " + f"SELECT {', '.join('i.' + c for c in BOARD_FIELDS)}, n.work_kind " "FROM issue i LEFT JOIN issue_note n ON n.key = i.key ORDER BY i.key")] links = [dict(r) for r in conn.execute( "SELECT source_key AS source, target_key AS target, link_type FROM issue_link " diff --git a/src/sdlc_hub/modules/jira/schema.py b/src/sdlc_hub/modules/jira/schema.py index 1248ee7..e728f11 100644 --- a/src/sdlc_hub/modules/jira/schema.py +++ b/src/sdlc_hub/modules/jira/schema.py @@ -77,7 +77,6 @@ CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at); CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at); CREATE TABLE IF NOT EXISTS issue_note ( key TEXT PRIMARY KEY, - fact_status TEXT, work_kind TEXT, done_text TEXT, rest_text TEXT, diff --git a/src/sdlc_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py index 2d78e91..6f110e9 100644 --- a/src/sdlc_hub/modules/jira/settings.py +++ b/src/sdlc_hub/modules/jira/settings.py @@ -17,18 +17,20 @@ DEFAULTS = { "priority_map": {}, "priority_value_key": "name", "labels": [], - "status_labels": {}, "kind_labels": {}, "comment_marker": "", - "transitions_map": {}, "link_types": {}, "closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed) } +# Ключи прежних версий (fact-status-removal): их удаляет миграция v8 и каждый ensure_defaults — процесс со старым кодом +# (MCP-сессия до reconnect) вставил бы их снова своими умолчаниями +OBSOLETE = ("status_labels", "transitions_map") + # Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link", - "priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker", - "transitions_map", "link_types", "pacing", "min_agent_version"] + "priority_map", "priority_value_key", "labels", "kind_labels", "comment_marker", + "link_types", "pacing", "min_agent_version"] def _is_int(v) -> bool: @@ -85,9 +87,20 @@ def _issue_types(cfg: dict) -> dict: return out +def migrate_v7(conn) -> None: + """Шаг миграции v7 → v8 (fact-status-removal): колонка issue_note.fact_status и ключи status_labels, transitions_map + удаляются безвозвратно; settings_version поднимается, если удалён хотя бы один ключ и строка версии есть.""" + if "fact_status" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")}: + conn.execute("ALTER TABLE issue_note DROP COLUMN fact_status") + gone = conn.execute(f"DELETE FROM setting WHERE key IN ({', '.join('?' * len(OBSOLETE))})", OBSOLETE).rowcount + if gone: + conn.execute("UPDATE setting SET value = CAST(value AS INTEGER) + 1 WHERE key = 'settings_version'") + + def init_from_config(conn, cfg: dict, force=False) -> list[str]: """Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira - (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают.""" + (auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают; status_labels и transitions — тоже + (статуса по факту в хабе нет, fact-status-removal).""" if core_settings.get(conn, "initialized") and not force: raise RuntimeError("хаб уже инициализирован; повторный init — с --force") project = cfg["project_key"] @@ -100,10 +113,8 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]: "priority_map": cfg.get("priority_map", {}), "priority_value_key": cfg.get("priority_value_key", "name"), "labels": cfg.get("labels", []), - "status_labels": cfg.get("status_labels", {}), "kind_labels": cfg.get("kind_labels", {}), "comment_marker": cfg.get("comment_marker", ""), - "transitions_map": cfg.get("transitions", {}).get("map", {}), "link_types": cfg.get("link_types", {}), "pacing": pacing_from_config(cfg.get("pacing", {})), } diff --git a/tests/fixtures/baseline/mcp_tools.expected.json b/tests/fixtures/baseline/mcp_tools.expected.json index cc7a33b..dd6551e 100644 --- a/tests/fixtures/baseline/mcp_tools.expected.json +++ b/tests/fixtures/baseline/mcp_tools.expected.json @@ -1,6 +1,6 @@ [ { - "description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.", + "description": "Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус Jira, метка, эпик,\nподстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.", "inputSchema": { "properties": { "assignee": { @@ -13,11 +13,6 @@ "title": "Epic", "type": "string" }, - "fact_status": { - "default": "", - "title": "Fact Status", - "type": "string" - }, "label": { "default": "", "title": "Label", @@ -183,18 +178,6 @@ "default": null, "title": "Done Text" }, - "fact_status": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Fact Status" - }, "key": { "title": "Key", "type": "string" diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index 545b629..c7288d8 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -73,7 +73,7 @@ [ "table", "issue_note", - "CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\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)" + "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", @@ -116,5 +116,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": 7 + "schema_version": 8 } diff --git a/tests/fixtures/baseline/settings.expected.json b/tests/fixtures/baseline/settings.expected.json index 79ac8d9..2e3eaa1 100644 --- a/tests/fixtures/baseline/settings.expected.json +++ b/tests/fixtures/baseline/settings.expected.json @@ -8,10 +8,8 @@ "priority_map", "priority_value_key", "labels", - "status_labels", "kind_labels", "comment_marker", - "transitions_map", "link_types", "pacing", "min_agent_version", @@ -51,8 +49,6 @@ "project_key": "AURORA", "relay_dir": null, "settings_version": 1, - "status_labels": {}, - "transitions_map": {}, "wiki_auto_sync_hours": 24, "wiki_min_agent_version": 1, "wiki_pacing": 0.2 diff --git a/tests/test_agents.py b/tests/test_agents.py index 9a6d2f6..9cf1078 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -22,7 +22,7 @@ def test_v6_upgrade_keeps_jira_agent(tmp_path): assert tok == "T-agent" code, body = api.h_claim(hub, conn, None, {"_agent": "jira"}, {"agent_version": 14}) assert code == 200 and body["agent_release"]["version"] == 14 # релиз v14 перенесён в release/jira/ - assert db._schema_version(conn) == 7 + assert db._schema_version(conn) == db.SCHEMA_VERSION tok = settings.tokens(conn) assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone() @@ -79,7 +79,8 @@ def test_module_agent_route_requires_own_module(hub_server_two_modules): def test_agent_settings_per_module(hub_server_two_modules): jira = hub_server_two_modules["agents"]["jira"].get("/agent/settings") stub = hub_server_two_modules["agents"]["stub"].get("/agent/settings") - assert "project_key" in jira and "transitions_map" in jira and "settings_version" in jira + assert "project_key" in jira and "link_types" in jira and "settings_version" in jira + assert "transitions_map" not in jira and "status_labels" not in jira assert "stub_key" not in jira and "board_id" not in jira assert set(stub) == {"stub_key", "settings_version"} @@ -318,7 +319,7 @@ def test_v14_agent_over_http_on_v6_base(tmp_path): conn.close() expected = json.loads((Path(__file__).parent / "fixtures" / "settings_v6.json").read_text(encoding="utf-8"))["agent_keys"] got = a.get("/agent/settings") - assert set(got) == set(expected) - {"board_id"} + assert set(got) == set(expected) - {"board_id", "status_labels", "transitions_map"} # fact-status-removal assert a.get("/agent/release")["version"] == 14 assert a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": []}], "links": [], "neighbor_keys": []}) == {"issues": 1, "links": 0} diff --git a/tests/test_api.py b/tests/test_api.py index 7b64479..6abe76b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -112,8 +112,14 @@ def test_interface_routes(hub, add_project): hub["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "sprint": "Спринт 2", "labels": []}, {"key": "A-2", "summary": "Д", "sprint": "Спринт 1", "labels": []}], "links": []}) assert [i["key"] for i in c.get("/issues", sprint="Спринт 2")["issues"]] == ["A-1"] - note = c.put("/issues/A-1/note", {"fact_status": "В работе", "updated_by": "owner"}) - assert note["fact_status"] == "В работе" and note["updated_by"] == "owner" + assert [i["key"] for i in c.get("/issues", fact_status="В работе")["issues"]] == ["A-1", "A-2"] # параметр игнорируется + assert all("fact_status" not in i for i in c.get("/issues")["issues"]) + with pytest.raises(HubError) as e: # статуса по факту нет: поле — неизвестное (fact-status-removal) + c.put("/issues/A-1/note", {"fact_status": "В работе", "work_kind": "Аналитика", "updated_by": "owner"}) + assert e.value.status == 422 and "неизвестные поля заметки: fact_status" in e.value.detail + assert c.get("/issues/A-1")["note"] is None # и work_kind не записан + note = c.put("/issues/A-1/note", {"work_kind": "Аналитика", "updated_by": "owner"}) + assert note["work_kind"] == "Аналитика" and note["updated_by"] == "owner" and "fact_status" not in note with pytest.raises(HubError) as e: c.put("/issues/A-1/note", {"summary": "x"}) assert e.value.status == 422 diff --git a/tests/test_cli.py b/tests/test_cli.py index 0799e63..57e443d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,7 +13,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) assert cli.main(["init", "--config", str(FIXTURE)]) == 0 out = capsys.readouterr().out - assert "transitions_map" in out and "агент jira: " in out + assert "link_types" in out and "transitions_map" not in out and "агент jira: " in out assert cli.main(["init", "--config", str(FIXTURE)]) == 1 assert "уже инициализирован" in capsys.readouterr().err conn = open_db(paths.db_path()) diff --git a/tests/test_db.py b/tests/test_db.py index f1076f7..3c67f36 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -19,12 +19,23 @@ 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 conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + 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)")} assert "deliveries" in cols +def _add_fact_status(conn): + """Колонка issue_note.fact_status, как в базах до v8 (fact-status-removal).""" + conn.execute("ALTER TABLE issue_note ADD COLUMN fact_status TEXT") + + +def _note_after_v8(conn, key): + """После миграции до v8: колонки fact_status нет; work_kind заметки.""" + assert "fact_status" not in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")} + return conn.execute("SELECT work_kind FROM issue_note WHERE key = ?", (key,)).fetchone()["work_kind"] + + def _drop_v6(conn): """Снять с базы текущей схемы то, что добавила v6 (вложения).""" conn.execute("DROP TABLE attachment_file") @@ -46,7 +57,8 @@ def _v1_database(path): for status in ("claimed", "done", "queued"): conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)", (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) - conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _add_fact_status(conn) + conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')") _drop_v5(conn) conn.execute("ALTER TABLE command DROP COLUMN deliveries") conn.execute("ALTER TABLE batch DROP COLUMN project") @@ -59,10 +71,10 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] - assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + assert _note_after_v8(conn, "A-1") == "Аналитика" copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite")) assert len(copies) == 1 old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию @@ -206,7 +218,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) conn.close() @@ -215,7 +227,8 @@ def test_migrates_v2_with_backup(tmp_path): path = tmp_path / "hub.sqlite" conn = db.connect(path) conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')") - conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _add_fact_status(conn) + conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')") _drop_v5(conn) conn.execute("DROP TABLE issue_comment") conn.execute("ALTER TABLE batch DROP COLUMN project") @@ -223,10 +236,10 @@ def test_migrates_v2_with_backup(tmp_path): conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + 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 issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" - assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" + assert _note_after_v8(conn, "A-1") == "Аналитика" copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite")) assert len(copies) == 1 conn.close() @@ -267,7 +280,8 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id= conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, command_id) VALUES (?, ?, 'jql', ?, ?)", (ts, ts, base_jql, cid)) conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,)) - conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _add_fact_status(conn) + conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')") _drop_v5(conn) conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") @@ -292,7 +306,7 @@ def test_migrates_v3_creates_awpca(tmp_path): queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] assert sorted(queued) == sorted([new, "key = A-1"]) assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -311,7 +325,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): conn = db.connect(tmp_path / "hub.sqlite") assert projects.list_projects(conn) == [] assert "ORDER BY key" in caplog.text - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) conn.close() @@ -345,7 +359,7 @@ def test_migrates_v4(tmp_path): path = tmp_path / "hub.sqlite" _v4_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION) rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)} assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None @@ -361,8 +375,85 @@ def test_migrate_v5_to_v6(tmp_path): conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7" + 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 issue_attachment").fetchone()["n"] == 0 assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1 conn.close() + + +def _v7_database(path, settings_version=12, with_keys=True): + """База v7: заметка со статусом по факту, ключи status_labels/transitions_map в setting (fact-status-removal).""" + conn = db.connect(path) + settings.ensure_defaults(conn) + _add_fact_status(conn) + conn.execute("INSERT INTO issue_note(key, fact_status, work_kind, updated_at, updated_by) " + "VALUES ('A-1', 'В работе', 'Аналитика', '2026-10-20T00:00:00+00:00', 'import')") + rows = [("settings_version", settings_version)] + if with_keys: + rows += [("status_labels", {"В работе": "fact-in-progress"}), ("transitions_map", {"Снято": "Снято"})] + for key, value in rows: + conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value, ensure_ascii=False))) + conn.execute("UPDATE setting SET value = '7' WHERE key = 'schema_version'") + conn.close() + + +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 _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") + assert settings.get(conn, "status_labels") is None and settings.get(conn, "transitions_map") is None + assert settings.get(conn, "settings_version") == 13 + assert len(list((tmp_path / "backups").glob("*-schema-v7.sqlite"))) == 1 + conn.close() + + +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 + conn.close() + + +def test_migrates_v7_without_settings_version(tmp_path): + path = tmp_path / "hub.sqlite" + _v7_database(path) + raw = sqlite3.connect(path) + raw.execute("DELETE FROM setting WHERE key = 'settings_version'") + raw.commit() + raw.close() + conn = db.connect(path) + assert db._schema_version(conn) == 8 + assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0 + conn.close() + + +def test_v8_reconnect_is_stable(tmp_path): + path = tmp_path / "hub.sqlite" + _v7_database(path) + db.connect(path).close() + conn = db.connect(path) + settings.ensure_defaults(conn) + assert settings.get(conn, "settings_version") == 13 + assert "status_labels" not in settings.get_all(conn) and "transitions_map" not in settings.get_all(conn) + conn.close() + + +def test_obsolete_keys_removed_by_ensure_defaults(tmp_path): + """I-1: процесс со старым кодом (MCP-сессия до reconnect) после миграции снова вставляет status_labels/transitions_map + своим ensure_defaults; новый ensure_defaults (старт хаба, новый MCP) их убирает.""" + path = tmp_path / "hub.sqlite" + _v7_database(path) + conn = db.connect(path) + for key in ("status_labels", "transitions_map"): # как INSERT OR IGNORE старых DEFAULTS + conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, '{}')", (key,)) + version = settings.get(conn, "settings_version") + settings.ensure_defaults(conn) + assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn)) + assert settings.get(conn, "settings_version") == version + conn.close() diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 0a2ddb6..7ef47f2 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -27,7 +27,7 @@ def live(tmp_path, add_project): def test_create_link_sprint_flow(live): agent, client = live - client.put("/issues/new:60/note", {"fact_status": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) + client.put("/issues/new:60/note", {"rest_text": "Ожидание разработки", "work_kind": "разработка", "updated_by": "import"}) b = client.post("/batches", {"title": "новая задача в спринт", "author": "claude", "commands": [ {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая", "duedate": "2026-10-23", "labels": ["AWPCA"]}}, diff --git a/tests/test_importer.py b/tests/test_importer.py index f26b5ac..7c032a2 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -48,7 +48,7 @@ def test_import_notes_and_report(conn, tmp_path): rep = importer.import_package(conn, tmp_path) assert rep["notes"] == 6 n = mirror.get_note(conn, "AURORA-1533") - assert n["fact_status"] == "Ожидание разработки" and n["work_kind"] == "тестирование" + assert n["work_kind"] == "тестирование" and "fact_status" not in n # колонка CSV пропускается (fact-status-removal) assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import" assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список" assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 35cfaeb..42ee818 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,3 +1,4 @@ +import inspect import asyncio import hashlib import json @@ -47,9 +48,9 @@ def test_tools_roundtrip(live): "assignee": "ivanov", "sprint": "Спринт 2", "labels": ["AWPCA"]}], "links": []}) out = jmcp.issues(sprint="Спринт 2") assert "A-1" in out and "Тема раз" in out and "ivanov" in out - assert "В работе" in jmcp.notes_set("A-1", fact_status="В работе") + assert "Аналитика" in jmcp.notes_set("A-1", work_kind="Аналитика") detail = jmcp.issue("A-1") - assert "fact_status: В работе" in detail and "Готово" in detail + assert "work_kind: Аналитика" in detail and "Готово" in detail created = jmcp.batch_create("срок", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}]) batch_id = int(created.split("#")[1].split()[0]) assert "2 команд" in created and "мин" in created @@ -73,11 +74,19 @@ def test_tools_roundtrip(live): assert jmcp.settings_set("tokens", '"x"').startswith("ошибка хаба 422") +def test_no_fact_status_params(): + for tool in (jmcp.issues, jmcp.notes_set): + assert "fact_status" not in inspect.signature(tool).parameters + + def test_formatters(): assert jmcp.fmt_issues([]) == "задач нет" text = jmcp.fmt_issues([{"key": "A-1", "summary": "x" * 100, "status": "Готово", "duedate": None, "assignee": None, - "sprint": None, "fact_status": None}]) + "sprint": None, "fact_status": "В работе"}]) assert "x" * 79 + "…" in text and "x" * 80 not in text and "—" in text + head, row = text.splitlines() + assert head == "ключ | тема | статус | срок | исполнитель | спринт" # статуса по факту нет (fact-status-removal) + assert len(row.split(" | ")) == 6 and "В работе" not in row assert "очередь" in jmcp.fmt_agent({"online": False, "agent_version": None, "current_command_id": None, "rate_limited_24h": 0, "redelivered": [], "warning": None, "last_seen_at": None, "settings_version": None}) diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 96c2aff..6baec01 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -60,14 +60,16 @@ def test_partial_load_never_lowers_neighbor(conn): def test_note_survives_refresh_and_renames(conn): - mirror.set_note(conn, "new:7", {"fact_status": "В работе", "work_kind": "разработка"}, "import") + mirror.set_note(conn, "new:7", {"rest_text": "В работе", "work_kind": "разработка"}, "import") mirror.rename_note(conn, "new:7", "A-5") mirror.apply_state(conn, [issue("A-5")], []) got = mirror.get_issue(conn, "A-5") - assert got["note"]["fact_status"] == "В работе" and got["note"]["updated_by"] == "import" + assert got["note"]["rest_text"] == "В работе" and got["note"]["updated_by"] == "import" assert mirror.get_note(conn, "new:7") is None with pytest.raises(ValueError): mirror.set_note(conn, "A-5", {"summary": "нельзя"}, "x") + with pytest.raises(ValueError, match="неизвестные поля заметки: fact_status"): # fact-status-removal + mirror.set_note(conn, "A-5", {"fact_status": "В работе"}, "x") def test_list_issues_filters(conn): @@ -86,9 +88,9 @@ def test_list_issues_filters(conn): def test_rename_note_same_key_keeps_note(conn): mirror.apply_state(conn, [issue("A-1")], []) - mirror.set_note(conn, "A-1", {"fact_status": "В работе"}, "x") + mirror.set_note(conn, "A-1", {"rest_text": "В работе"}, "x") mirror.rename_note(conn, "A-1", "A-1") - assert mirror.get_note(conn, "A-1")["fact_status"] == "В работе" + assert mirror.get_note(conn, "A-1")["rest_text"] == "В работе" def test_rename_note_missing_is_noop(conn): @@ -99,32 +101,32 @@ def test_rename_note_missing_is_noop(conn): def test_set_note_partial_keeps_other_fields(conn): mirror.set_note(conn, "A-1", {"work_kind": "разработка", "done_text": "сделано"}, "x") - got = mirror.set_note(conn, "A-1", {"fact_status": "Готово"}, "y") + got = mirror.set_note(conn, "A-1", {"rest_text": "Готово"}, "y") assert got["work_kind"] == "разработка" and got["done_text"] == "сделано" - assert got["fact_status"] == "Готово" and got["updated_by"] == "y" + assert got["rest_text"] == "Готово" and got["updated_by"] == "y" def test_rename_note_merges_with_existing(conn): mirror.set_note(conn, "A-5", {"owner_note": "спросить ИБ", "work_kind": "Старое"}, "human") - mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "fact_status": "В работе"}, "import") + mirror.set_note(conn, "new:5", {"work_kind": "Аналитика", "rest_text": "В работе"}, "import") mirror.rename_note(conn, "new:5", "A-5") note = mirror.get_note(conn, "A-5") assert note["work_kind"] == "Аналитика" # непустое поле временной заметки побеждает assert note["owner_note"] == "спросить ИБ" # пустое у временной — остаётся под ключом - assert note["fact_status"] == "В работе" + assert note["rest_text"] == "В работе" assert note["updated_by"] == "import" # временная заметка записана позже assert mirror.get_note(conn, "new:5") is None def test_rename_note_keeps_newer_real_key_stamp(conn): - mirror.set_note(conn, "new:6", {"fact_status": "В работе"}, "import") + mirror.set_note(conn, "new:6", {"rest_text": "В работе"}, "import") mirror.set_note(conn, "A-6", {"owner_note": "позже"}, "human") conn.execute("UPDATE issue_note SET updated_at = '2020-01-01T00:00:00+00:00' WHERE key = 'new:6'") conn.execute("UPDATE issue_note SET updated_at = '2030-01-01T00:00:00+00:00' WHERE key = 'A-6'") mirror.rename_note(conn, "new:6", "A-6") note = mirror.get_note(conn, "A-6") assert note["updated_by"] == "human" and note["updated_at"] == "2030-01-01T00:00:00+00:00" - assert note["fact_status"] == "В работе" and note["owner_note"] == "позже" + assert note["rest_text"] == "В работе" and note["owner_note"] == "позже" def test_rename_note_empty_string_does_not_overwrite(conn): @@ -160,9 +162,9 @@ def test_board_snapshot_raw_mirror_data(conn): assert [i["key"] for i in snap["issues"]] == ["AURORA-1351", "AURORA-1620", "AURORA-1621"] a = snap["issues"][1] assert set(a) == {"key", "summary", "issue_type", "status", "priority", "assignee", "sprint", "duedate", - "planned_start", "planned_end", "epic_key", "labels", "neighbor", "fact_status", "work_kind"} + "planned_start", "planned_end", "epic_key", "labels", "neighbor", "work_kind"} assert a["labels"] == ["kind-dev"] and a["epic_key"] == "AURORA-1351" - assert a["work_kind"] == "разработка" and a["fact_status"] is None and a["neighbor"] is False + assert a["work_kind"] == "разработка" and a["neighbor"] is False assert snap["issues"][2]["neighbor"] is True and snap["issues"][2]["work_kind"] is None assert snap["links"] == [{"source": "AURORA-1620", "target": "AURORA-1621", "link_type": "ASUPR_Блокер_Начала"}] assert snap["last_sync"] is None and snap["generated_at"] diff --git a/tests/test_queue.py b/tests/test_queue.py index e587a36..779d5d0 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -101,7 +101,7 @@ def test_backup_failure_does_not_block_claim(conn, caplog): def test_temp_key_substitution_and_skip(conn, add_project): add_project(conn) - mirror.set_note(conn, "new:60", {"fact_status": "В работе"}, "import") + mirror.set_note(conn, "new:60", {"rest_text": "В работе"}, "import") queue.create_batch(conn, "создать", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Новая"}}, {"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}}, @@ -111,7 +111,7 @@ def test_temp_key_substitution_and_skip(conn, add_project): ]) c1 = queue.claim(conn, "jira") queue.report_result(conn, c1["id"], "done", result={"key": "A-90"}) - assert mirror.get_note(conn, "A-90")["fact_status"] == "В работе" and mirror.get_note(conn, "new:60") is None + assert mirror.get_note(conn, "A-90")["rest_text"] == "В работе" and mirror.get_note(conn, "new:60") is None c2 = queue.claim(conn, "jira") assert c2["type"] == "ensure_link" and c2["params"]["source"] == "A-90" queue.report_result(conn, c2["id"], "done") @@ -362,7 +362,7 @@ def test_report_for_queued_command_rejected(conn): def test_late_report_after_force_cancel_keeps_issue_key(conn): - mirror.set_note(conn, "new:7", {"fact_status": "В работе"}, "import") + mirror.set_note(conn, "new:7", {"rest_text": "В работе"}, "import") queue.create_batch(conn, "x", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:7", "issue_type": "task", "summary": "Тема"}}]) c = queue.claim(conn, "jira") @@ -370,7 +370,7 @@ def test_late_report_after_force_cancel_keeps_issue_key(conn): late = queue.report_result(conn, c["id"], "done", result={"key": "A-20", "created": True}, jira_calls=3) assert late["status"] == "cancelled" and late["result"] == {"key": "A-20", "created": True} and late["jira_calls"] == 3 assert late["finished_at"] == cancelled["finished_at"] - assert mirror.get_note(conn, "A-20")["fact_status"] == "В работе" and mirror.get_note(conn, "new:7") is None + assert mirror.get_note(conn, "A-20")["rest_text"] == "В работе" and mirror.get_note(conn, "new:7") is None again = queue.report_result(conn, c["id"], "done", result={"key": "A-21"}) assert again["result"] == {"key": "A-20", "created": True} # второй поздний отчёт ничего не меняет @@ -399,7 +399,7 @@ def test_error_is_trimmed_to_1500(conn): def test_late_done_after_delivery_limit_keeps_issue_key(conn): - mirror.set_note(conn, "new:8", {"fact_status": "План"}, "import") + mirror.set_note(conn, "new:8", {"rest_text": "План"}, "import") queue.create_batch(conn, "x", "claude", [ {"type": "ensure_issue", "params": {"temp_key": "new:8", "issue_type": "task", "summary": "Тема"}}]) c = queue.claim(conn, "jira") @@ -407,7 +407,7 @@ def test_late_done_after_delivery_limit_keeps_issue_key(conn): late = queue.report_result(conn, c["id"], "done", result={"key": "A-30", "created": True}, jira_calls=2) assert late["status"] == "failed" and late["result"] == {"key": "A-30", "created": True} assert late["error"] == "агент не отчитался после 3 выдач" - assert mirror.get_note(conn, "A-30")["fact_status"] == "План" and mirror.get_note(conn, "new:8") is None + assert mirror.get_note(conn, "A-30")["rest_text"] == "План" and mirror.get_note(conn, "new:8") is None again = queue.report_result(conn, c["id"], "done", result={"key": "A-31"}) assert again["result"] == {"key": "A-30", "created": True} # повтор ничего не меняет diff --git a/tests/test_settings.py b/tests/test_settings.py index 337bf24..ecf0971 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -55,7 +55,9 @@ def test_protected_keys(conn, key): def test_init_from_config(conn): cfg = json.loads(FIXTURE.read_text(encoding="utf-8")) keys = jira_settings.init_from_config(conn, cfg) - assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys + assert "link_types" in keys and "base_jql" not in keys and "board_id" not in keys + assert "transitions_map" not in keys and "status_labels" not in keys # fact-status-removal + assert {"transitions_map", "status_labels"}.isdisjoint(settings.get_all(conn)) 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"} diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index b18ebee..56450e2 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -22,12 +22,12 @@ def own_labels(i): return labels if i["neighbor"] or "AWPCA" in labels else ["AWPCA", *labels] -issues = [{k: v for k, v in i.items() if k not in ("fact_status", "work_kind", "neighbor")} | {"id": i["key"]} +issues = [{k: v for k, v in i.items() if k not in ("work_kind", "neighbor")} | {"id": i["key"]} | {"labels": own_labels(i)} | extra_issues.get(i["key"], {}) for i in snap["issues"]] links = [{"source_key": l["source"], "target_key": l["target"], "link_type": l["link_type"]} for l in snap["links"]] mirror.apply_state(conn, issues, links, neighbor_keys=[i["key"] for i in snap["issues"] if i["neighbor"]]) for i in snap["issues"]: - note = {k: i[k] for k in ("fact_status", "work_kind") if i.get(k)} + note = {k: i[k] for k in ("work_kind",) if i.get(k)} if note: mirror.set_note(conn, i["key"], note, "seed") if extras.get("commands"): # завершённые команды по задачам — для раздела «Команды моста» карточки diff --git a/web/src/modules/jira/board/__fixtures__/hub-basic.board.json b/web/src/modules/jira/board/__fixtures__/hub-basic.board.json index c5dc7be..1b828b7 100644 --- a/web/src/modules/jira/board/__fixtures__/hub-basic.board.json +++ b/web/src/modules/jira/board/__fixtures__/hub-basic.board.json @@ -2,16 +2,16 @@ "generated_at": "2026-10-14T10:00:00+00:00", "last_sync": {"started_at": "2026-10-14T09:00:00+00:00", "finished_at": "2026-10-14T09:01:00+00:00", "issues_count": 10}, "issues": [ - {"key": "AURORA-9100", "summary": "Тестовый эпик", "issue_type": "Фича", "status": "В работе", "priority": "Средний", "assignee": null, "sprint": null, "duedate": null, "planned_start": null, "planned_end": null, "epic_key": null, "labels": ["AWPCA"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9001", "summary": "Схема свода", "issue_type": "Задача", "status": "В работе", "priority": "Высокий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-30", "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["AWPCA", "kind-dev"], "neighbor": false, "fact_status": "В работе", "work_kind": "разработка"}, - {"key": "AURORA-9002", "summary": "API свода", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Средний", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-20", "planned_start": null, "planned_end": null, "epic_key": "AURORA-9100", "labels": ["kind-dev"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9003", "summary": "Макет экрана", "issue_type": "Задача", "status": "Backlog", "priority": "Критичный", "assignee": null, "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-16", "epic_key": "AURORA-9100", "labels": ["kind-ui"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9004", "summary": "Решение по порогам", "issue_type": "Задача", "status": "К выполнению", "priority": "Наивысший", "assignee": "Сидорова Анна", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": "2026-10-25", "epic_key": "AURORA-1359", "labels": ["kind-decision"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9005", "summary": "Тест-кейсы", "issue_type": "Задача", "status": "Ожидание", "priority": "Низкий", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": null, "epic_key": "AURORA-1359", "labels": ["kind-test"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9006", "summary": "Задача без эпика", "issue_type": "Задача", "status": "Готово", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": null, "duedate": "2026-10-10", "planned_start": null, "planned_end": null, "epic_key": null, "labels": [], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9007", "summary": "Сосед по связям", "issue_type": "Задача", "status": "В работе", "priority": "Средний", "assignee": "Kozlov-AA", "sprint": "AWPCA_Спринт 1", "duedate": null, "planned_start": null, "planned_end": "2026-10-12", "epic_key": "AURORA-9100", "labels": ["kind-analytics"], "neighbor": true, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9008", "summary": "Актуализация ТЗ", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Самый низкий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-18", "epic_key": "AURORA-9999", "labels": ["kind-docs"], "neighbor": false, "fact_status": null, "work_kind": null}, - {"key": "AURORA-9009", "summary": "Созвон с заказчиком", "issue_type": "Задача", "status": "", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["kind-org"], "neighbor": false, "fact_status": null, "work_kind": null} + {"key": "AURORA-9100", "summary": "Тестовый эпик", "issue_type": "Фича", "status": "В работе", "priority": "Средний", "assignee": null, "sprint": null, "duedate": null, "planned_start": null, "planned_end": null, "epic_key": null, "labels": ["AWPCA"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9001", "summary": "Схема свода", "issue_type": "Задача", "status": "В работе", "priority": "Высокий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-30", "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["AWPCA", "kind-dev"], "neighbor": false, "work_kind": "разработка"}, + {"key": "AURORA-9002", "summary": "API свода", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Средний", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 2", "duedate": "2026-10-20", "planned_start": null, "planned_end": null, "epic_key": "AURORA-9100", "labels": ["kind-dev"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9003", "summary": "Макет экрана", "issue_type": "Задача", "status": "Backlog", "priority": "Критичный", "assignee": null, "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-16", "epic_key": "AURORA-9100", "labels": ["kind-ui"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9004", "summary": "Решение по порогам", "issue_type": "Задача", "status": "К выполнению", "priority": "Наивысший", "assignee": "Сидорова Анна", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": "2026-10-25", "epic_key": "AURORA-1359", "labels": ["kind-decision"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9005", "summary": "Тест-кейсы", "issue_type": "Задача", "status": "Ожидание", "priority": "Низкий", "assignee": "Петров Пётр Петрович", "sprint": "AWPCA_Спринт 3", "duedate": null, "planned_start": null, "planned_end": null, "epic_key": "AURORA-1359", "labels": ["kind-test"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9006", "summary": "Задача без эпика", "issue_type": "Задача", "status": "Готово", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": null, "duedate": "2026-10-10", "planned_start": null, "planned_end": null, "epic_key": null, "labels": [], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9007", "summary": "Сосед по связям", "issue_type": "Задача", "status": "В работе", "priority": "Средний", "assignee": "Kozlov-AA", "sprint": "AWPCA_Спринт 1", "duedate": null, "planned_start": null, "planned_end": "2026-10-12", "epic_key": "AURORA-9100", "labels": ["kind-analytics"], "neighbor": true, "work_kind": null}, + {"key": "AURORA-9008", "summary": "Актуализация ТЗ", "issue_type": "Задача", "status": "Ожидание разработки", "priority": "Самый низкий", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-18", "epic_key": "AURORA-9999", "labels": ["kind-docs"], "neighbor": false, "work_kind": null}, + {"key": "AURORA-9009", "summary": "Созвон с заказчиком", "issue_type": "Задача", "status": "", "priority": "Средний", "assignee": "Иванов Иван Иванович", "sprint": "AWPCA_Спринт 2", "duedate": null, "planned_start": null, "planned_end": "2026-10-20", "epic_key": "AURORA-9100", "labels": ["kind-org"], "neighbor": false, "work_kind": null} ], "links": [ {"source": "AURORA-9001", "target": "AURORA-9002", "link_type": "ASUPR_Блокер_Начала"}, diff --git a/web/src/modules/jira/board/__fixtures__/settings.json b/web/src/modules/jira/board/__fixtures__/settings.json index a66c22d..19dd452 100644 --- a/web/src/modules/jira/board/__fixtures__/settings.json +++ b/web/src/modules/jira/board/__fixtures__/settings.json @@ -1,6 +1,5 @@ { "kind_labels": {"аналитика": "kind-analytics", "решение": "kind-decision", "актуализация": "kind-docs", "разработка": "kind-dev", "UI": "kind-ui", "тестирование": "kind-test", "организационная": "kind-org"}, "link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "блокирует завершение": "ASUPR_Блокер_Завершения", "связанные": "ASUPR_Связанные"}, - "status_labels": {"Готово — закрыть": "fact-done", "В работе": "fact-in-progress", "Ожидание разработки": "fact-open"}, "closed_statuses": ["Готово", "Снято"] }