Compare commits
7 Commits
5a218259d8
...
0a64a5b1d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a64a5b1d1 | ||
|
|
9aaf3cee50 | ||
|
|
a5d9526b4f | ||
|
|
8bab76e043 | ||
|
|
5b01b91710 | ||
|
|
6c352dd851 | ||
|
|
06ef35a013 |
@ -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`); иначе старый код увидит «публикации нет» и самообновление агента молчит.
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
12
README.md
12
README.md
@ -44,7 +44,7 @@ uv run sdlc-hub serve # 0.0.0.0:8790
|
||||
uv run sdlc-hub import ~/projects/awp-ca/admin/jira-sync
|
||||
```
|
||||
|
||||
Переносит из CSV заметки (статус по факту, вид работы и т. п.) и выводит отчёт JSON:
|
||||
Переносит из CSV заметки (вид работы, что сделано, что осталось; колонку «Статус по факту» пропускает) и выводит отчёт JSON:
|
||||
`notes` (сколько заметок записано), `expected_links`, `missing_keys` (ключи из CSV, которых нет в зеркале) и
|
||||
`missing_links` (ожидаемые связи, которых нет), `reversed_links` (ожидаемые связи, которые в Jira стоят в обратную сторону — для «Связанных» это та же связь). Запускать после первого `refresh` с `full: true`: пока зеркало пусто,
|
||||
все ключи будут в `missing_keys`. Заметки к ещё не загруженным задачам сохраняются.
|
||||
@ -213,10 +213,10 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
|
||||
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
|
||||
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |
|
||||
| `GET /projects`, `POST /projects`, `PATCH /projects/{метка}` | клиент | проекты `{projects: […]}`: метка, название, `created_at` и поля модулей — jira: `board_id`, `board_type`, `issues_count`, `jira_synced_at` (время последнего полного обновления из Jira; `null` — ещё не было), wiki: `wiki_root`, `wiki_synced_at`, `wiki_sync_error`. `POST` заводит `{label, name, поля модулей?, author?}` → 201 `{project, batch_id, batches}`: пакеты, поставленные модулями (`batches: [{module, id}]`; у jira — «Новый проект <метка>», `refresh` по общей JQL; `batch_id` — пакет первого модуля); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка отказана). `PATCH` меняет изменяемые поля модулей (иные — 422 `bad_project`) → 200 `{project, batch_ids, batches}` |
|
||||
| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` |
|
||||
| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` |
|
||||
| `GET /issues/{key}` | клиент | задача целиком, с комментариями и журналом изменений `changes` (новые сверху) |
|
||||
| `GET /changes` | клиент | лента изменений: `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `project` (только свои задачи), `key`, `kinds`, `limit` (до 2000) → `{since, until, truncated, changes}` |
|
||||
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
|
||||
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `work_kind` из заметок; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
|
||||
| `GET /`, `GET /links` | — | перенаправление на `/app/` («Обзор») |
|
||||
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
|
||||
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта, `?module=<имя>` — только пакеты модуля (по типу первой команды; неизвестный модуль — 404), `limit`; в каждом пакете поле `module` (`null` — не определён) |
|
||||
@ -245,9 +245,9 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.sdlc-hub
|
||||
make web
|
||||
```
|
||||
|
||||
Порядок важен для версии со схемой v7 (модуль wiki): сразу после `git pull` — kickstart, потом `make web`. До kickstart не
|
||||
запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и переводят её в v7, а хаб на прежнем коде
|
||||
с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` —
|
||||
Порядок важен для версий с новой схемой (v7 — модуль wiki, v8 — без статуса по факту): сразу после `git pull` — kickstart,
|
||||
потом `make web`. До kickstart не запускать MCP и CLI `sdlc-hub` (`tokens`, `mcp`, `import`): они открывают базу и
|
||||
переводят её в новую схему, а хаб на прежнем коде с такой базой не работает. Старый фронт с новым API совместим, сборка может подождать. Откат на копию v6 из `backups/` —
|
||||
вместе с возвратом `release/jira/*` в `release/` (прежняя раскладка), иначе старый код не найдёт публикацию агента.
|
||||
|
||||
Проект выбирается выпадающим списком «Проект» в верхней панели (метка и название); пункт «+ Новый проект…» открывает окно
|
||||
|
||||
202
docs/superpowers/plans/2026-10-24-fact-status-removal.md
Normal file
202
docs/superpowers/plans/2026-10-24-fact-status-removal.md
Normal 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)"
|
||||
```
|
||||
2
openspec/changes/fact-status-removal/.openspec.yaml
Normal file
2
openspec/changes/fact-status-removal/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
129
openspec/changes/fact-status-removal/design.md
Normal file
129
openspec/changes/fact-status-removal/design.md
Normal 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
|
||||
— те же документы. Цена ошибки — нет.
|
||||
49
openspec/changes/fact-status-removal/proposal.md
Normal file
49
openspec/changes/fact-status-removal/proposal.md
Normal 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).
|
||||
152
openspec/changes/fact-status-removal/specs/data-model/spec.md
Normal file
152
openspec/changes/fact-status-removal/specs/data-model/spec.md
Normal 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` их не
|
||||
отдаёт
|
||||
143
openspec/changes/fact-status-removal/specs/hub-api/spec.md
Normal file
143
openspec/changes/fact-status-removal/specs/hub-api/spec.md
Normal 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
|
||||
95
openspec/changes/fact-status-removal/specs/mcp/spec.md
Normal file
95
openspec/changes/fact-status-removal/specs/mcp/spec.md
Normal 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»
|
||||
42
openspec/changes/fact-status-removal/specs/migration/spec.md
Normal file
42
openspec/changes/fact-status-removal/specs/migration/spec.md
Normal 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:*` для неё не создаётся
|
||||
31
openspec/changes/fact-status-removal/specs/modules/spec.md
Normal file
31
openspec/changes/fact-status-removal/specs/modules/spec.md
Normal 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`) нет
|
||||
27
openspec/changes/fact-status-removal/specs/web-app/spec.md
Normal file
27
openspec/changes/fact-status-removal/specs/web-app/spec.md
Normal 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` «тестирование»); окно закрывается без потери раскладки и прокрутки доски
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 — плохо)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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})$")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", {})),
|
||||
}
|
||||
|
||||
19
tests/fixtures/baseline/mcp_tools.expected.json
vendored
19
tests/fixtures/baseline/mcp_tools.expected.json
vendored
@ -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"
|
||||
|
||||
4
tests/fixtures/baseline/schema.expected.json
vendored
4
tests/fixtures/baseline/schema.expected.json
vendored
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
|
||||
117
tests/test_db.py
117
tests/test_db.py
@ -19,12 +19,23 @@ def test_connect_creates_schema(open_db):
|
||||
assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")}
|
||||
assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")}
|
||||
assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")}
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
|
||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
|
||||
assert "deliveries" in cols
|
||||
|
||||
|
||||
def _add_fact_status(conn):
|
||||
"""Колонка issue_note.fact_status, как в базах до v8 (fact-status-removal)."""
|
||||
conn.execute("ALTER TABLE issue_note ADD COLUMN fact_status TEXT")
|
||||
|
||||
|
||||
def _note_after_v8(conn, key):
|
||||
"""После миграции до v8: колонки fact_status нет; work_kind заметки."""
|
||||
assert "fact_status" not in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")}
|
||||
return conn.execute("SELECT work_kind FROM issue_note WHERE key = ?", (key,)).fetchone()["work_kind"]
|
||||
|
||||
|
||||
def _drop_v6(conn):
|
||||
"""Снять с базы текущей схемы то, что добавила v6 (вложения)."""
|
||||
conn.execute("DROP TABLE attachment_file")
|
||||
@ -46,7 +57,8 @@ def _v1_database(path):
|
||||
for status in ("claimed", "done", "queued"):
|
||||
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)",
|
||||
(status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None))
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||
_add_fact_status(conn)
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
|
||||
_drop_v5(conn)
|
||||
conn.execute("ALTER TABLE command DROP COLUMN deliveries")
|
||||
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||
@ -59,10 +71,10 @@ def test_migrates_v1_with_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v1_database(path)
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall()
|
||||
assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)]
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
assert _note_after_v8(conn, "A-1") == "Аналитика"
|
||||
copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite"))
|
||||
assert len(copies) == 1
|
||||
old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию
|
||||
@ -206,7 +218,7 @@ def test_parallel_connect_makes_one_backup(tmp_path):
|
||||
assert errors == []
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -215,7 +227,8 @@ def test_migrates_v2_with_backup(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
conn = db.connect(path)
|
||||
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||
_add_fact_status(conn)
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
|
||||
_drop_v5(conn)
|
||||
conn.execute("DROP TABLE issue_comment")
|
||||
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||
@ -223,10 +236,10 @@ def test_migrates_v2_with_backup(tmp_path):
|
||||
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
|
||||
assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
|
||||
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||
assert _note_after_v8(conn, "A-1") == "Аналитика"
|
||||
copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite"))
|
||||
assert len(copies) == 1
|
||||
conn.close()
|
||||
@ -267,7 +280,8 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=
|
||||
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, command_id) VALUES (?, ?, 'jql', ?, ?)",
|
||||
(ts, ts, base_jql, cid))
|
||||
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,))
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||
_add_fact_status(conn)
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
|
||||
_drop_v5(conn)
|
||||
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||
conn.execute("DROP TABLE project")
|
||||
@ -292,7 +306,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
|
||||
queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")]
|
||||
assert sorted(queued) == sorted([new, "key = A-1"])
|
||||
assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
|
||||
conn.close()
|
||||
|
||||
@ -311,7 +325,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
|
||||
conn = db.connect(tmp_path / "hub.sqlite")
|
||||
assert projects.list_projects(conn) == []
|
||||
assert "ORDER BY key" in caplog.text
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
conn.close()
|
||||
|
||||
|
||||
@ -345,7 +359,7 @@ def test_migrates_v4(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v4_database(path)
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")}
|
||||
assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)}
|
||||
assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None
|
||||
@ -361,8 +375,85 @@ def test_migrate_v5_to_v6(tmp_path):
|
||||
conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
conn = db.connect(path)
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "7"
|
||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_attachment").fetchone()["n"] == 0
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
def _v7_database(path, settings_version=12, with_keys=True):
|
||||
"""База v7: заметка со статусом по факту, ключи status_labels/transitions_map в setting (fact-status-removal)."""
|
||||
conn = db.connect(path)
|
||||
settings.ensure_defaults(conn)
|
||||
_add_fact_status(conn)
|
||||
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind, updated_at, updated_by) "
|
||||
"VALUES ('A-1', 'В работе', 'Аналитика', '2026-10-20T00:00:00+00:00', 'import')")
|
||||
rows = [("settings_version", settings_version)]
|
||||
if with_keys:
|
||||
rows += [("status_labels", {"В работе": "fact-in-progress"}), ("transitions_map", {"Снято": "Снято"})]
|
||||
for key, value in rows:
|
||||
conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value, ensure_ascii=False)))
|
||||
conn.execute("UPDATE setting SET value = '7' WHERE key = 'schema_version'")
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrates_v7_drops_fact_status(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v7_database(path)
|
||||
conn = db.connect(path)
|
||||
assert db._schema_version(conn) == 8
|
||||
assert _note_after_v8(conn, "A-1") == "Аналитика"
|
||||
row = conn.execute("SELECT updated_at, updated_by FROM issue_note WHERE key = 'A-1'").fetchone()
|
||||
assert (row["updated_at"], row["updated_by"]) == ("2026-10-20T00:00:00+00:00", "import")
|
||||
assert settings.get(conn, "status_labels") is None and settings.get(conn, "transitions_map") is None
|
||||
assert settings.get(conn, "settings_version") == 13
|
||||
assert len(list((tmp_path / "backups").glob("*-schema-v7.sqlite"))) == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrates_v7_without_keys_keeps_settings_version(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v7_database(path, with_keys=False)
|
||||
conn = db.connect(path)
|
||||
assert db._schema_version(conn) == 8 and settings.get(conn, "settings_version") == 12
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_migrates_v7_without_settings_version(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v7_database(path)
|
||||
raw = sqlite3.connect(path)
|
||||
raw.execute("DELETE FROM setting WHERE key = 'settings_version'")
|
||||
raw.commit()
|
||||
raw.close()
|
||||
conn = db.connect(path)
|
||||
assert db._schema_version(conn) == 8
|
||||
assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v8_reconnect_is_stable(tmp_path):
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v7_database(path)
|
||||
db.connect(path).close()
|
||||
conn = db.connect(path)
|
||||
settings.ensure_defaults(conn)
|
||||
assert settings.get(conn, "settings_version") == 13
|
||||
assert "status_labels" not in settings.get_all(conn) and "transitions_map" not in settings.get_all(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_obsolete_keys_removed_by_ensure_defaults(tmp_path):
|
||||
"""I-1: процесс со старым кодом (MCP-сессия до reconnect) после миграции снова вставляет status_labels/transitions_map
|
||||
своим ensure_defaults; новый ensure_defaults (старт хаба, новый MCP) их убирает."""
|
||||
path = tmp_path / "hub.sqlite"
|
||||
_v7_database(path)
|
||||
conn = db.connect(path)
|
||||
for key in ("status_labels", "transitions_map"): # как INSERT OR IGNORE старых DEFAULTS
|
||||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, '{}')", (key,))
|
||||
version = settings.get(conn, "settings_version")
|
||||
settings.ensure_defaults(conn)
|
||||
assert {"status_labels", "transitions_map"}.isdisjoint(settings.get_all(conn))
|
||||
assert settings.get(conn, "settings_version") == version
|
||||
conn.close()
|
||||
|
||||
@ -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"]}},
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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} # повтор ничего не меняет
|
||||
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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"): # завершённые команды по задачам — для раздела «Команды моста» карточки
|
||||
|
||||
@ -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_Блокер_Начала"},
|
||||
|
||||
@ -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": ["Готово", "Снято"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user