Andrey Epifancev 30833a466a Архив OpenSpec: sprints-backlog (#108)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:24:17 +04:00

408 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 = <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
менять.
Миграция 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:<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: Пакеты и команды
Таблица `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 = <project_key> 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` — нет