# data-model Specification ## Purpose Модель данных хаба: один файл SQLite с зеркалом задач Jira, служебными заметками, пакетами и командами, журналом синхронизаций, статусом агента и настройками. Здесь таблицы, их смысл, правила записи (зеркало пишет только загрузка состояния от агента) и миграции схемы. ## Requirements ### Requirement: Схема базы хаба База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_attachment`, `attachment_file`, `issue_note`, `issue_sprint`, `jira_board`, `jira_sprint`, `batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при старте по номеру версии в `setting.schema_version` (текущая — 9). Миграция существующей базы SHALL выполняться в одной транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в `backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`, `issue_attachment.created`) и значений полей журнала. Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю настройку `base_jql` вида `… labels = ` или `… labels in (, …)` и завести проекты с этими метками (название проекта `AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в `queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи `base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с прежним значением. Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и `sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`. Миграция v5 → v6 SHALL: создать пустые `issue_attachment` (с индексом по `key`) и `attachment_file`. Списки вложений появляются с первым `refresh` агента v14. Миграция v7 → v8 SHALL: удалить колонку `issue_note.fact_status` (если она есть; значения удаляются безвозвратно, копия — в `backups/` по общему правилу) и ключи `status_labels` и `transitions_map` из `setting`, если они есть, и поднять `settings_version`, если хотя бы один из ключей был удалён. Прочие поля заметок, задачи и настройки миграция MUST NOT менять. Миграция v8 → v9 SHALL: добавить колонки `issue.rank` и `issue.story_points` (у имеющихся задач `NULL`); таблицы `jira_board`, `jira_sprint`, `issue_sprint` создаются схемой пустыми. Данные появляются с первым `refresh` агента v15. #### 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` = текущая #### Scenario: Миграция базы v8 - **WHEN** хаб стартует с базой `schema_version = 8`, в зеркале задача X со спринтом «Спринт 2» - **THEN** в `backups/` копия базы v8; у X `rank` и `story_points` — `NULL`, `sprint` прежний; таблицы `jira_board`, `jira_sprint`, `issue_sprint` пусты; `schema_version = 9` #### 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` SHALL хранить задачу Jira как есть: `key` (PK), `id`, `issue_type`, `summary`, `description` (wiki), `status`, `priority`, `assignee`, `reporter`, `duedate`, `planned_start`, `planned_end`, `epic_key`, `labels` (JSON-список), `sprint`, `rank` (ранг Jira строкой как есть или `NULL`), `story_points` (оценка, число или `NULL`), `jira_updated`, `fetched_at`, `neighbor` (0/1 — вне базовой JQL, дочитана по связи), `created`. Таблица `issue_link` SHALL хранить связи `(source_key, target_key, link_type)` с направлением по семантике Jira: `source` блокирует/ссылается на `target` (у `source` в GET это `outwardIssue`). Таблица `issue_comment` SHALL хранить комментарии задачи как есть: `key`, `comment_id` (PK вместе с `key`), `author`, `created`, `updated`, `body` (wiki). Таблица `issue_attachment` SHALL хранить список вложений задачи без содержимого: `attachment_id` (PK, id вложения Jira), `key`, `filename`, `size` (байт или `NULL`), `mime_type`, `author`, `created` (время Jira как есть), `created_at` (UTC, приведённое из `created`; не разобралось — `NULL`). Обновлять `issue`, `issue_link`, `issue_comment` и `issue_attachment` SHALL только загрузка состояния от агента; HTTP API интерфейсов и MCP MUST NOT писать в них. Присланные комментарии задачи SHALL заменять все её прежние комментарии; задача, присланная без поля `comments`, свои комментарии SHALL сохранять. Так же со вложениями: присланное поле `attachments` заменяет все прежние вложения задачи, задача без поля (агент до v14) свои вложения сохраняет. Полный прогон (`refresh` по базовой JQL), завершившийся `done`, SHALL помечать `neighbor = 1` задачи, не обновлённые им, — только если прогон загрузил хотя бы одну задачу. #### Scenario: Загрузка состояния заменяет связи - **WHEN** агент прислал состояние задачи X с двумя связями - **THEN** все строки `issue_link` с `source_key = X` заменены на присланные; строки с `target_key = X`, не присланные со стороны X, не трогаются до загрузки их источника #### Scenario: Соседняя задача - **WHEN** в `issuelinks` задачи из базовой JQL есть ключ Y вне JQL - **THEN** Y сохраняется с `neighbor = 1`; при следующем полном `refresh`, если Y вошла в JQL, `neighbor = 0` #### Scenario: Пустой полный прогон - **WHEN** полный `refresh` завершился `done`, загрузив 0 задач - **THEN** признак `neighbor` задач зеркала не меняется #### Scenario: Комментарий удалён в Jira - **WHEN** у X в зеркале комментарии 101 и 102, агент прислал X с `comments = [{id: "102", …}]` - **THEN** у X в `issue_comment` только 102 #### Scenario: Состояние от агента до v10 - **WHEN** у X в зеркале три комментария, агент v9 прислал X без поля `comments` - **THEN** поля X обновлены, три комментария на месте #### Scenario: Вложения в состоянии - **WHEN** у X в зеркале вложения #1 и #2, агент v14 прислал X с `attachments = [{id: "2", …}, {id: "3", …}]` - **THEN** у X в `issue_attachment` #2 (поля из состояния) и #3; #1 удалено; `attachment_file` не тронута #### Scenario: Состояние от агента до v14 - **WHEN** у X в зеркале два вложения, агент v13 прислал X без поля `attachments` - **THEN** поля X обновлены, два вложения на месте ### Requirement: Служебные заметки Таблица `issue_note` SHALL хранить поля, которых нет в Jira: `key` (PK; ключ Jira или временный `new:`), `work_kind`, `done_text`, `rest_text`, `owner_note`, `updated_at`, `updated_by`. Обновление зеркала MUST NOT менять `issue_note`. При завершении `ensure_issue` с временным ключом хаб SHALL перенести заметку на настоящий ключ — в том числе по позднему отчёту `done` с ключом о команде, отменённой или переведённой в `failed` до отчёта. Если под настоящим ключом заметка уже есть, хаб SHALL слить их: каждое поле берётся из временной заметки, если там оно непустое, иначе остаётся под настоящим ключом; `updated_at`/`updated_by` — от более поздней; временная строка удаляется. #### Scenario: Заметка переживает refresh - **WHEN** у задачи есть `issue_note.work_kind = "Аналитика"` и приходит полное обновление зеркала - **THEN** `work_kind` не изменился #### Scenario: Слияние с заметкой под ключом - **WHEN** у `new:5` заметка `work_kind = "Аналитика"`, `owner_note` пусто; у `AURORA-1640` заметка `owner_note = "спросить ИБ"`; `ensure_issue` для `new:5` завершилась с `AURORA-1640` - **THEN** у `AURORA-1640` `work_kind = "Аналитика"` и `owner_note = "спросить ИБ"`; строки `new:5` нет ### Requirement: Пакеты и команды Таблица `batch` SHALL хранить `id`, `title`, `author`, `created_at`, `cancelled_at`, `estimate_seconds`, `project` (метка проекта, если пакет поставлен в разрезе проекта, иначе `NULL`). Таблица `command` SHALL хранить `id`, `batch_id`, `seq`, `type`, `params` (JSON), `status` (`queued | claimed | done | failed | skipped | cancelled | confirmed_by_state`), `claimed_at` (время последней выдачи), `deliveries` (сколько раз команда выдана агенту), `finished_at`, `result` (JSON), `error` (до 1500 символов), `jira_calls`, `rate_limited` (число 429/503), `pause_seconds` (выданная пауза), `depends_on` (id команды, чей результат подставляется). Статус пакета SHALL вычисляться из команд и `cancelled_at`, не храниться: есть команды не в терминальном статусе — «выполняется» (или «в очереди», если все `queued`); все терминальные и пакет отменён (`cancelled_at`) — «отменён»; все терминальные без отмены — «завершён». #### Scenario: Статус пакета - **WHEN** в пакете 3 команды: `done`, `claimed`, `queued` - **THEN** статус пакета «выполняется»; когда все в терминальных статусах — «завершён», с числом `failed`/`skipped` #### Scenario: Отменённый пакет с выполненной командой - **WHEN** в пакете команда 1 `done`, команда 2 `claimed`, команда 3 `queued`; пакет отменён, затем агент отчитался по 2 - **THEN** статус пакета «отменён» (`cancelled`), счётчики показывают две `done` и одну `cancelled` ### Requirement: Журнал синхронизаций и статус агента Таблица `sync_run` SHALL хранить каждую загрузку состояния: `started_at`, `finished_at`, `kind` (`keys | jql`), `query`, `issues_count`, `links_count`, `jira_calls`, `rate_limited`, `command_id`. При повторной выдаче команды `refresh` хаб SHALL обнулить `issues_count` и `links_count` её `sync_run` (`started_at` не меняется); при переводе `refresh` в `failed` по пределу выдач — проставить `finished_at`. Таблица `agent_status` SHALL хранить по строке на агента (ключ `agent` — имя модуля, см. `modules`, «Агенты модулей»): `last_seen_at`, `agent_version`, `settings_version`, `current_command_id`, `last_call_at` (время последнего обращения агента к внешней системе; от jira-agent приходит полем `last_jira_call_at`). Через API и MCP статус агента SHALL отдаваться с признаком `online` (истина, если `last_seen_at` моложе 5 минут) и списком `redelivered` — команд его модуля в `claimed`, выданных больше одного раза (`id`, `type`, `claimed_at`, `deliveries`). #### Scenario: Агент давно не выходил на связь - **WHEN** `last_seen_at` агента `jira` старше 5 минут - **THEN** `agent_status` через API и MCP отдаётся с `online = false` #### Scenario: Довыдача refresh не удваивает счётчики - **WHEN** `refresh` загрузил 150 задач, отчёт потерян, команда выдана снова и загрузила те же 150 - **THEN** у её `sync_run` `issues_count = 150` #### Scenario: refresh сдался по пределу выдач - **WHEN** `refresh` переведён в `failed` «агент не отчитался после 3 выдач» - **THEN** у его `sync_run` проставлен `finished_at` #### Scenario: Статусы агентов раздельно - **WHEN** агент `jira` выходил на связь минуту назад, агент `wiki` — никогда - **THEN** `GET /agents` отдаёт `jira` с `online = true` и `wiki` с `online = false`, `last_seen_at = null` ### 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` их не отдаёт ### Requirement: Проекты Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: `label` (PK, метка Jira), `name`, `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть непустым после обрезки пробелов, до 100 символов. Метку и название после создания хаб не меняет (переименования и удаления нет). Доска проекта SHALL храниться в таблице модуля jira `jira_project`: `label` (PK, ссылка на проект), `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — тип ещё не известен). `board_id` — целое больше нуля или `NULL`, после создания не меняется; `board_type` SHALL записываться только по результату `refresh` от агента (см. `agent`). Миграция v7 SHALL перенести `board_id` и `board_type` всех проектов из `project` в `jira_project`. Базовая JQL SHALL вычисляться из проектов: `project = AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем label, через `, `; без проектов базовой JQL нет (`null`). Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без метки P, связанные со своей связью `issue_link` в любую сторону, и эпики своих (`epic_key` своей задачи), если они есть в зеркале. Признак `neighbor` в выдаче в разрезе проекта SHALL считаться относительно P: `false` у своих, `true` у соседей. #### Scenario: Базовая JQL из двух проектов - **WHEN** заведены проекты `AWPCA` (раньше) и `STOCKS_DRRK`, `project_key = AURORA` - **THEN** базовая JQL — `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` #### Scenario: Свои и соседи - **WHEN** у AURORA-1 метка `STOCKS_DRRK` и эпик AURORA-9 (метка `AWPCA`), связь AURORA-1 → AURORA-2 (метка `AWPCA`), у AURORA-3 метка `AWPCA` без связей с проектом `STOCKS_DRRK` - **THEN** в разрезе `STOCKS_DRRK` свои — AURORA-1, соседи — AURORA-2 и AURORA-9; AURORA-3 в разрез не входит #### Scenario: Метка с пробелом - **WHEN** создаётся проект с меткой `STOCKS DRRK` - **THEN** отказ с указанием поля `label`, проекта нет #### Scenario: Метка с обратной косой чертой или управляющим символом - **WHEN** создаётся проект с меткой `A\B` или `A<перевод строки>B` - **THEN** отказ с указанием поля `label`, проекта нет #### Scenario: Метка — зарезервированное слово JQL - **WHEN** заведён проект с меткой `EMPTY` - **THEN** базовая JQL — `project = AURORA AND labels in ("EMPTY")`, метка не расширяет выборку #### Scenario: Та же метка другим регистром - **WHEN** есть проект `AWPCA`, создаётся проект `awpca` - **THEN** отказ «проект с такой меткой уже есть», проект один #### Scenario: Доски после миграции v7 - **WHEN** база v6 с проектами `AWPCA` (`board_id = 4216`, `scrum`) и `sfm_kedr` (без доски) мигрирует в v7 - **THEN** в `project` нет колонок `board_id`, `board_type`; в `jira_project` — `AWPCA` 4216 `scrum` и `sfm_kedr` `NULL` `NULL`; перед миграцией сделана копия базы ### Requirement: Журнал изменений задач Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом), `created` (время Jira как есть), `author`, `field` (логическое имя), `field_id`, `field_name`, `from_value`, `from_string`, `to_value`, `to_string`, `fetched_at`; PK `(history_id, seq)`, индексы по `at` и по `(key, at)`. Записывать в `issue_change` SHALL только приём журнала от агента (`POST /agent/history`); повторно присланная строка с тем же `(history_id, seq)` MUST NOT менять сохранённую. HTTP API интерфейсов и MCP MUST NOT писать в `issue_change`. Логическое имя `field` SHALL вычисляться хабом при приёме: `status`, `description`, `summary`, `assignee`, `priority`, `duedate`, `labels`, `resolution` — если так называется `field_id`, а при его отсутствии — `field_name`; `planned_start`, `planned_end` — если `field_id` равен id этого поля в настройке `field_ids`; `epic_link` — если `field_id` равен `epic_link.field_id`; иначе — `field_name` как есть. Время Jira (`created` журнала, `created` комментария) SHALL приводиться к UTC разбором ISO-8601 со смещением вида `+0300`, `+03:00` или `Z`, с дробной частью секунд или без; время без смещения считается UTC; неразбираемое — `NULL` в приведённой колонке (строка журнала с `at = NULL` в ленту не попадает). Таблица `issue_comment` SHALL хранить также `created_at` — `created`, приведённое к UTC, заполняемое при каждой записи комментариев. #### Scenario: Повтор записи журнала - **WHEN** агент дважды прислал элемент `{history_id: "5001", seq: 0, key: X, field_name: "status", …}` - **THEN** в `issue_change` одна строка `(5001, 0)`; второй приём ничего не изменил #### Scenario: Плановая дата по id поля - **WHEN** в настройках `field_ids.planned_end = "customfield_10201"`, агент прислал элемент с `field_id = "customfield_10201"`, `field_name = "Плановое окончание"` - **THEN** у строки `field = "planned_end"` #### Scenario: Неизвестное поле - **WHEN** агент прислал элемент с `field_id = null`, `field_name = "Fix Version"` - **THEN** у строки `field = "Fix Version"` #### Scenario: Время со смещением - **WHEN** элемент журнала с `created = "2026-10-22T09:00:00.000+0300"` - **THEN** у строки `at = "2026-10-22T06:00:00+00:00"`, `created` — как прислано ### Requirement: Отметка журнала в журнале синхронизаций Таблица `sync_run` SHALL хранить `history_at` — время, когда хаб принял итог журнала по этому прогону (`NULL` — журнал по прогону не принят). Хаб SHALL проставлять `history_at` при отчёте полного прогона (`refresh` по базовой JQL) со статусом `done` и `result.history` без `error`; у прочих `refresh`, при `failed`, `cancelled`, без `result.history` или с `history.error` — оставлять `NULL`. `history.backfill_error` и непустой `history.incomplete` отметку не держат. #### Scenario: Журнал принят - **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 3, changes: 7, incomplete: []}` - **THEN** у его `sync_run` проставлен `history_at` #### Scenario: Ошибка стартовой загрузки не держит отметку - **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 1, changes: 2, incomplete: [], backfill_error: "HTTP 400 …"}` - **THEN** у его `sync_run` проставлен `history_at` #### Scenario: Ошибка журнала - **WHEN** `refresh` завершился `done` с `result.history = {error: "HTTP 500 …"}` - **THEN** у его `sync_run` `history_at = NULL` #### Scenario: Старый агент - **WHEN** агент v12 завершил `refresh` `done` без `result.history` - **THEN** у его `sync_run` `history_at = NULL` ### Requirement: Скачанные вложения Таблица `attachment_file` SHALL хранить, какие вложения уже скачаны в хранилище файлов хаба: `attachment_id` (PK), `file` (sha256 содержимого), `size`, `fetched_at` (UTC). Строку SHALL записывать (вставка или замена) только отчёт `done` по `fetch_attachment`; загрузка состояния её MUST NOT трогать, поэтому скачанный файл переживает обновление списка вложений. Строка без файла в хранилище (файл убран уборкой) SHALL считаться «не скачано». Состояние вложения для интерфейсов (`state`) SHALL вычисляться при чтении: - `ready` — есть строка `attachment_file` и файл `file` есть в хранилище; - `fetching` — нет `ready`, и есть `fetch_attachment` с этим `attachment_id` в `queued` или `claimed`; - `failed` — нет ни того, ни другого, а последняя (по `id`) `fetch_attachment` с этим `attachment_id` — `failed` или `cancelled`; её `error` отдаётся вместе с состоянием; - `none` — вложение ни разу не запрашивали (или запрос был до уборки файла). #### Scenario: Файл убран уборкой - **WHEN** у вложения #31337 строка `attachment_file`, файла `file` в `files/` нет; команд по нему в очереди нет, последняя `fetch_attachment` по нему — `done` - **THEN** `state = none`; новый запрос на скачивание ставит новую `fetch_attachment` #### Scenario: Список вложений обновился - **WHEN** вложение #31337 скачано (`state = ready`), затем пришло состояние задачи с тем же вложением - **THEN** `state` по-прежнему `ready` ### Requirement: Доски, спринты и членство задач Модуль jira SHALL хранить данные досок проектов, присланные агентом в `refresh` (см. `hub-api`, «Приём досок и спринтов»): - `jira_board`: `board_id` (PK), `type` (`scrum` | `kanban` | `NULL`), `columns` (JSON-список `[{name, statuses: [имя статуса]}]` в порядке доски или `NULL`), `kanban_backlog` (1 / 0 / `NULL` — неизвестно), `fetched_at` (UTC); - `jira_sprint`: `sprint_id` (PK, id спринта Jira), `board_id`, `name`, `state` (`active` | `future` | `closed`), `start_date`, `end_date`, `complete_date` (строки Jira как есть или `NULL`), `goal`; - `issue_sprint`: `key`, `sprint_id` (PK — пара) — все спринты задачи любого состояния. Писать их SHALL только загрузка состояния от агента. Выполненной на доске SHALL считаться задача, чей `status` входит в `statuses` последней колонки доски; колонок нет (`NULL`) — выполненных нет. #### Scenario: Задача прошла два спринта - **WHEN** агент прислал задачу X с `sprint_ids: [101, 102]`, спринт 101 закрыт, 102 активен - **THEN** у X две строки `issue_sprint`; X в составе закрытого спринта 101 и активного 102 #### Scenario: Колонки доски - **WHEN** у доски 4216 колонки «К выполнению» [`Backlog`, `Ожидает анализа`], «В работе» […], «Готово» [`Готово`, `Протестировано`, `Снято`] - **THEN** задача в статусе `Протестировано` выполнена, в статусе `Backlog` — нет