Compare commits

...

7 Commits

Author SHA1 Message Date
Andrey Epifancev
0a64a5b1d1 design: решения исполнения fact-status-removal (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:23:04 +04:00
Andrey Epifancev
9aaf3cee50 Устаревшие ключи настроек удаляет ensure_defaults; выкатка схемы v8 в документах (#104)
I-1, I-2 финального ревью: старые MCP-сессии возвращали status_labels/transitions_map
после миграции; окно до kickstart описано для v8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:22:44 +04:00
Andrey Epifancev
a5d9526b4f Веб-сидер, фикстуры и README без статуса по факту (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:06:26 +04:00
Andrey Epifancev
8bab76e043 Схема v8: без статуса по факту и его настроек (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:59:05 +04:00
Andrey Epifancev
5b01b91710 Модуль jira: заметки, API, MCP и импорт без статуса по факту (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:47:12 +04:00
Andrey Epifancev
6c352dd851 План: удаление статуса по факту (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:39:38 +04:00
Andrey Epifancev
06ef35a013 OpenSpec: change fact-status-removal (#104)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 16:37:54 +04:00
38 changed files with 1101 additions and 118 deletions

View File

@ -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:<n>`; смысл типов команд берёт у модуля через `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`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
## Открытые вопросы

View File

@ -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/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно

View File

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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View File

@ -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
— те же документы. Цена ошибки — нет.

View File

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

View File

@ -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 = <M>` или `… labels in (<M1>, …)` и завести проекты с этими метками (название проекта
`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:<n>`),
`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` их не
отдаёт

View File

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

View File

@ -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 последних (номер «#<comment_id>», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: #<id> <имя>, …»; затем вложения (см. «Вложения в MCP») |
| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка «<key> #<id> <автор>, создан <created>» (и «, изменён <updated>», если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у <key> нет комментария #<id>»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «#<id> <имя> (<размер>)» |
| `attachment` | `key`, `attachment` (номер вложения «#<id>» или «<id>», иначе — точное имя файла), `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<N> от <дата>» или «публикации нет»), состояние обновления («обновление: <state> v<from> → v<to>»), запускатель («запускатель: v<N>», «без запускателя» или, пока `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: <sha>, filename: "ОСВ сч. 60 за 2 кв.
2026.xlsx"}` и хвостовым `refresh`
#### Scenario: Файла нет
- **WHEN** тот же вызов, файла по `path` нет
- **THEN** ответ «файл не найден: <path>», пакет не создан, в хаб ничего не загружено
#### 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 последних комментариев с номерами «#<comment_id>» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2»
#### Scenario: Комментарий целиком
- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}`
- **THEN** ответ — «AURORA-9001 #101 <автор>, создан <created>» и полный текст комментария без обрезки, переносы строк сохранены
#### Scenario: Нет такого комментария
- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999
- **THEN** ответ «у AURORA-9001 нет комментария #999»

View File

@ -0,0 +1,42 @@
## MODIFIED Requirements
### Requirement: Инициализация настроек
`sdlc-hub init --config <admin/jira-sync/config.example.json>` 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:*` для неё не создаётся

View File

@ -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`) нет

View File

@ -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` «тестирование»); окно закрывается без потери раскладки и прокрутки доски

View File

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

View File

@ -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 — плохо)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", {})),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"]}},

View File

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

View File

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

View File

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

View File

@ -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} # повтор ничего не меняет

View File

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

View File

@ -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"): # завершённые команды по задачам — для раздела «Команды моста» карточки

View File

@ -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_Блокер_Начала"},

View File

@ -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": ["Готово", "Снято"]
}