diff --git a/openspec/changes/change-history/.openspec.yaml b/openspec/changes/change-history/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/change-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/change-history/design.md b/openspec/changes/change-history/design.md new file mode 100644 index 0000000..dbdcd4e --- /dev/null +++ b/openspec/changes/change-history/design.md @@ -0,0 +1,218 @@ +## Context + +`main` 7911dc7, схема v4, агент v12. Устройство, на которое опираются правки: + +- **Зеркало** — `mirror.apply_state` делает upsert строки `issue` (прежние значения теряются) и заменяет комментарии + задачи присланными (`issue_comment`, время `created`/`updated` — строка Jira как есть, например + `2026-10-22T13:05:11.000+0300`). Поле `jira_updated` (Jira `updated`) уже приходит в состоянии. +- **Полный прогон** — `refresh {jql: base_jql}` (`queue.is_full_refresh`); его ставят автообновление + (`queue.maybe_auto_refresh`, по умолчанию раз в 60 минут, `auto_refresh_minutes`), `POST /refresh {full}` и MCP + `refresh full`. Хвост пакета — `refresh {keys}` по ключам пакета. `base_jql` вычисляется из проектов и меняется при + создании проекта. +- **Выдача** — `queue.deliver_params` дописывает параметры при выдаче (у `refresh` — `boards`), в базе `params` без них. + `report_result` по `refresh` пишет `sync_run.finished_at` и типы досок из `result.boards`. +- **Агент** (`jira-agent/src/agent.py`, v12, Python 3.9 stdlib): `do_refresh` → `search_all(jql)` с полями зеркала + (включая `updated`), дочитывание соседей (`search_tolerant`, `key in (...)`), имена спринтов, отправка + `POST /agent/state` пачками по 100. Неизвестные параметры команды агент игнорирует (`p.get`). +- **Спринт в зеркале** — имя активного или будущего спринта доски проекта; у задач закрытых спринтов `sprint = null`. + Активный и будущий в зеркале не различаются. +- **MCP** — `mcp_server.py` ходит только через `client`; `issue` форматирует `GET /issues/{key}` (комментарии — 5 + последних). +- **Время** — в базе ISO-8601 UTC (`db.now()`), кроме дат без времени и времени комментариев. + +## Goals / Non-Goals + +**Goals:** журнал изменений задач из Jira в хабе; лента событий за период в API и MCP; история задачи в +`GET /issues/{key}` и MCP `issue`; стартовая загрузка по незакрытым задачам текущего спринта; регулярная загрузка только +по изменившимся задачам; ни одно изменение не теряется молча. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Таблица `issue_change`, схема v5.** Колонки: `history_id` (TEXT, id записи журнала Jira), `seq` (INTEGER, + порядковый номер поля в записи, с 0), `key`, `at` (ISO-8601 UTC — время записи, приведённое хабом), `created` (время + Jira как есть), `author` (отображаемое имя, иначе логин, иначе `NULL`), `field` (логическое имя, см. решение 3), + `field_id` (id поля Jira или `NULL`), `field_name` (имя поля Jira как есть), `from_value`, `from_string`, `to_value`, + `to_string` (как есть, могут быть `NULL`), `fetched_at`. PK `(history_id, seq)`; индексы `(at)` и `(key, at)`. + Запись — `INSERT OR IGNORE`: повтор записи журнала ничего не меняет. Пишет таблицу только приём журнала от агента; + API и MCP — только читают (как зеркало). + *Почему по строке на поле, а не на запись журнала:* лента фильтрует по виду поля, одна запись Jira часто меняет + статус и резолюцию разом. +2. **Время комментариев в UTC.** Колонка `issue_comment.created_at` (ISO-8601 UTC, приведённое из `created`; не + разобралось — `NULL`), заполняется в `apply_state`; миграция v4 → v5 заполняет её у имеющихся строк. Строка `created` + остаётся как есть (её показывают интерфейсы). Время журнала приводится так же (`at`). Разбор: ISO-8601 со смещением + `+0300` / `+03:00` / `Z`, с дробной частью или без; без смещения — UTC. +3. **Логическое имя поля.** Хаб вычисляет `field` при приёме: `status`, `description`, `summary`, `assignee`, + `priority`, `duedate`, `labels`, `resolution` — по `field_id`, а без него — по `field_name` (встроенные поля Jira + называются так же); `planned_start`, `planned_end`, `epic_link` — по `field_id`, совпавшему с `field_ids` / + `epic_link.field_id` настроек; остальное — `field_name` как есть. Хаб, а не агент: соответствие логических имён и id + полей уже живёт в настройках хаба, агент шлёт сырые данные. +4. **Виды событий ленты.** `status` — поле `status`; `description` — поле `description`; `dates` — поля `duedate`, + `planned_start`, `planned_end`; `comment` — комментарий с `created_at` в окне (из `issue_comment`, не из журнала); + `other` — прочие поля журнала. По умолчанию лента отдаёт `status`, `description`, `dates`, `comment`; `other` — только + по запросу (`kinds=all` или явно). Остальные поля журнала сохраняются всегда — приходят бесплатно, понадобятся вебу. +5. **Отметка «с какого момента» — из `sync_run`, без новой настройки.** Колонка `sync_run.history_at` (время приёма + итога журнала, `NULL` — журнал по прогону не принят). Хаб проставляет её только у полного прогона (`refresh` по + базовой JQL), завершившегося `done` с `result.history` без `error` (решение 11). Отметка — `started_at` последнего + `sync_run` с `history_at IS NOT NULL` **независимо от `query`** (новый проект не обнуляет отметку прежних). Параметр + `history` при выдаче `refresh`: + - полный прогон, отметка есть — `{since: <отметка − 15 минут>}` (UTC, ISO-8601 с `Z`); если `query` прогона-отметки + не равна текущей `base_jql` (после него заведён проект) — ещё и `jql` стартовой загрузки (решение 6): прежние + проекты дочитываются от отметки, новый получает стартовую историю; + - полный прогон, отметки нет — `{jql: <стартовая>}`; + - `refresh {keys}` и `refresh` по небазовой JQL — `{since}`, если отметка есть, иначе `history` не дописывается + (стартовую загрузку делает только полный прогон). + Запас 15 минут покрывает расхождение часов Mac mini и сервера Jira и время самого прогона; повторы отбрасываются по + PK. Отметка сдвигается **только** полным прогоном с принятым журналом: прогон упал, регулярная часть журнала не + дочитана, агент старый — следующий прогон возьмёт прежнюю отметку и дочитает пропущенное (анти-цель «молча теряет»). + *Отвергнуто:* отдельная настройка-отметка (её правят руками и через `settings_set`, сдвиг надо делать атомарно с + итогом прогона); сравнение `jira_updated` в хабе с ответом агенту списком изменившихся (хаб уже перезаписал строку, если + журнал потом не дочитан — изменение потеряно); отметка по `query` = текущая `base_jql` (после нового проекта прежние + проекты теряли изменения вне спринта между отметкой и прогоном — находка рецензента). +6. **Стартовая загрузка.** `history.jql = () AND sprint in openSprints() AND statusCategory != Done`. Агент + тянет журнал этих задач целиком (без отсечения по времени). Решение владельца: «максимум незакрытые в текущем + спринте». `openSprints()` — активные спринты, будущие не входят. Категория статуса, а не настройка `closed_statuses`: + статус из настройки, которого нет в Jira, даёт 400 на всю JQL, и стартовая загрузка не прошла бы никогда. Стартовая + загрузка — разовая и «по возможности»: её ошибка не мешает отметке (решение 11) и не повторяется. Проект на + канбан-доске без спринтов в стартовую загрузку не попадает — его история начнётся с первого регулярного прогона. +7. **Агент v13: регулярный режим (`history.since`).** После отправки состояния (все пачки `POST /agent/state`) агент + отбирает из выгруженных задач (база и соседи) те, у кого Jira `updated >= since` (сравнение моментов времени с + учётом смещения), и по ним делает `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей. + Из журнала каждой задачи берёт записи с `created >= since`. Изменившихся задач нет — ни одного лишнего вызова Jira. + Решение владельца: без второго «слепого» запроса по `updated >= …`, отбор по уже выгруженному. +8. **Агент v13: неполный журнал.** Если у задачи `changelog.total` больше числа записей в ответе `search`, агент дочитывает + журнал `GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и там неполный — передаёт что есть, пишет в + лог (ключ, сколько получено из скольких) и добавляет ключ в `result.history.incomplete`. Отметку неполный журнал не + держит: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда; неполнота видна в результате + команды. Jira Server/DC, по ожиданию, отдаёт журнал целиком; проверить на живом агенте (план, живая проверка). + Элемент, который хаб отверг бы (пустые `created` или `field_name`, запись без `id`), агент отбрасывает с записью в + лог — иначе один кривой элемент давал бы 422 на пачку в каждом прогоне и отметка не сдвинулась бы никогда. +9. **Агент v13: id поля.** `field_id` — `fieldId` элемента журнала, если Jira его отдаёт; иначе для `fieldtype = custom` + агент ищет id по имени поля в `GET /rest/api/2/field` (один вызов на `refresh`, только если такой элемент встретился); + для `fieldtype = jira` — `field` как есть. Не нашлось — `null`. +10. **Приём журнала — `POST /agent/history`.** Тело `{command_id, changes: [...]}`, элемент — `{history_id, seq, key, + created, author, field_id, field_name, from, from_string, to, to_string}`. Агент шлёт пачками по 500 элементов; + хаб проверяет форму всей пачки до записи (`history_id`, `key`, `created`, `field_name` — непустые строки, `seq` — + целое ≥ 0, остальные — строка или `null`; нарушение — 422 `bad_history` с номером элемента и полем, пачка не + применяется) и пишет `INSERT OR IGNORE`. Ответ `{accepted, duplicates}`. Отдельный маршрут, а не поле в + `/agent/state`: журнал тянется после состояния, у него свои пачки, контракт состояния не меняется. +11. **Итог журнала — в результате `refresh`.** `result.history = {issues: <задач с журналом>, changes: <элементов + отправлено>, incomplete: [ключи с неполным журналом]}` плюс, при ошибке, `error` (регулярная часть, `since`) или + `backfill_error` (стартовая загрузка, `jql`). Ответ не 2xx от Jira или хаба на шаге журнала (`HttpError`, у хаба — с + префиксом «хаб: ») не роняет `refresh`: агент сразу, без повтора команды, возвращает `done` с текстом ошибки — зеркало + уже обновлено, ломать и перекачивать его ради журнала нельзя (анти-цель); повторит следующий прогон, отметка ведь + не сдвинулась. Обрыв связи с Jira или хабом (`ConnectionError`) на шаге журнала — как в остальном `refresh` (повтор, + затем `failed`). Без `history` в параметрах агент журнал не читает и ключа `history` в результате нет. Хаб ставит + `sync_run.history_at` при `done` полного прогона и `history` без `error`; `backfill_error` и `incomplete` отметку не + держат (стартовая загрузка разовая — решение 6; неполнота — решение 8). +11a. **Журнал в `sync_log`.** `GET /sync-runs` отдаёт `history_at`; MCP `sync_log` выводит у прогона «журнал: принят + <время>» или «журнал: нет». Текст ошибки журнала — в результате команды (`batch_status`). +12. **Лента — `GET /changes`.** Параметры: `since`, `until` (ISO-8601 со смещением или `Z`; без `until` — сейчас; без + `since` — `until − 24 ч`), `project` (только свои задачи проекта; соседи — нет: в ленте проекта чужие задачи — + шум), `key` (одна задача, без ограничения проектом), `kinds` (через запятую из `status`, `description`, `dates`, + `comment`, `other`, или `all`; по умолчанию все, кроме `other`), `limit` (по умолчанию 500, до 2000). Ответ + `{since, until, changes: [...], truncated}`, элемент — `{at, key, summary, status, kind, field, author, from, to, + from_value, to_value}` (`from`/`to` — `from_string`/`to_string`, а если они `null` — `from_value`/`to_value`; + `from_value`/`to_value` — сырые значения журнала: у дат Jira строка журнала может быть локализованной, а значение — + ISO, им пользуется MCP; у комментария `from = null`, `to` — текст, `field = "comment"`, `*_value = null`); порядок — `at` по убыванию, затем `key`, `history_id`, `seq`. Неизвестный проект + — 404, неверное время или вид — 422 `bad_request` с именем параметра. +13. **История задачи — в `GET /issues/{key}`.** Поле `changes` — все элементы журнала задачи (все виды, включая `other`), + в формате элемента ленты без `summary`/`status`, новые сверху. Комментарии там уже есть отдельно. +14. **MCP `changes`.** Параметры `hours` (число), `since`, `until`, `kinds`, `key`. Без `hours` и `since` — с начала + сегодняшнего дня по местному времени машины MCP. Привязка: в сессии проекта P — `project = P`; с `key` — без + проекта; без привязки и без `key` — ответ «сессия не привязана к проекту…» (как `issues`). Вид ответа — по задачам + (задачи — по времени последнего события, новые сверху): строка «КЛЮЧ тема — статус», под ней события по времени + («ЧЧ:ММ автор: статус «В работе» → «Готово»»; дата перед временем, если событие не сегодня; описание — «описание + изменено» без текста; срок — «срок 25.10 → 30.10» (дата — из начала `*_value` вида `ГГГГ-ММ-ДД`, иначе строка как + есть); комментарий — первые 200 символов с «…»). Пусто — «за период + изменений нет». `truncated` — строка «показаны последние N событий, сузьте период». +15. **MCP `issue`.** После комментариев — «изменения (N):» и 5 последних элементов `changes` в том же виде, «ещё M», если + их больше; без журнала — строки нет (у старых задач журнала может не быть). +16. **Миграция v4 → v5.** В той же транзакции с копией базы, что и прежние: `issue_change` и индексы создаёт `SCHEMA`; + шаг — `ALTER TABLE issue_comment ADD COLUMN created_at TEXT`, `ALTER TABLE sync_run ADD COLUMN history_at TEXT`, + заполнение `issue_comment.created_at` разбором `created` (Python-шаг, как `migrate_v3`). У имеющихся `sync_run` + `history_at = NULL` → первый `refresh` после выкатки хаба — стартовая загрузка. +17. **Порядок выкатки.** Хаб можно выкатить раньше агента: агент v12 игнорирует `history`, результат без `history` — + отметка не сдвигается, `history_at` пуст, каждый полный `refresh` выдаётся со стартовой JQL, но агент её не исполняет. + После публикации v13 первый полный прогон делает стартовую загрузку. Агент v13 со старым хабом (без `history` в + параметрах) журнал не читает. + +## Risks / Trade-offs + +- **Объём стартовой загрузки.** Журнал задач открытого спринта целиком; у задач с длинной историей описания — большие + тексты (`from_string`/`to_string` описаний), идут через общую папку. Пачки по 500 элементов; если пачка окажется + слишком тяжёлой для реле — уменьшить в агенте. +- **Расхождение часов > 15 минут** между Mac mini и сервером Jira даёт пропуски. Цена — потеря событий без сигнала; + принимаем, часы синхронизируются по NTP. +- **Удалённый комментарий** исчезает из ленты задним числом (лента читает `issue_comment`). +- **Журнал соседей.** Соседи, уже известные хабу, полным прогоном не перечитываются (поведение `refresh` v12), поэтому + их изменения попадают в журнал только при точечном обновлении. Принимаем: лента проекта показывает свои задачи. + +## Migration Plan + +1. PR хаба: схема v5, приём журнала, `history` в выдаче `refresh`, `GET /changes`, `changes` в `GET /issues/{key}`, + MCP. После merge — `git pull`, перезапуск хаба владельцем, `/mcp` → reconnect. +2. PR агента: v13. После merge — `jira-hub agent publish ../jira-agent/src/agent.py`; `agent_status` показывает v13; + `min_agent_version` не поднимать (хаб работает и со старым агентом). +3. Живая проверка: полный `refresh` (выдан с `history.jql`) → в `result.history` элементы, в ленте события задач + спринта; следующий полный `refresh` (выдан с `history.since`) → лишних вызовов Jira нет при отсутствии изменений; правка задачи в Jira → событие в + `changes` с автором и временем; лог агента — нет строк о неполном журнале (иначе — решение 8 под пересмотр). + +Откат: агент — публикация v12 невозможна (только вверх), выкатывается v14 без журнала; хаб — предыдущий коммит, +таблица и колонки v5 остаются и не мешают (старый код их не читает), `schema_version = 5` старый хаб не понизит. + +## Open Questions + +Нет. + +## Decisions made without discussion + +1. 2026-10-22 — **строка журнала — одно поле одной записи Jira (`issue_change`), PK `(history_id, seq)`.** Обоснование: + фильтр ленты по виду поля; идемпотентность по id Jira. Цена ошибки: если `seq` у одной записи Jira окажется + неустойчивым между запросами, возможны дубли полей одной записи — видно в ленте, лечится сменой ключа на + `(history_id, field_name, to_value)`. +2. 2026-10-22 — **сохраняются все поля журнала, лента по умолчанию показывает четыре вида.** Обоснование: данные + приходят бесплатно, веб и история задачи покажут больше. Цена ошибки: рост базы — текстами описаний. +3. 2026-10-22 — **отметка «с какого момента» — из `sync_run.history_at` любого полного прогона с принятым журналом + (без сравнения `query`), сдвигается только им; запас 15 минут; после нового проекта — `since` и `jql` вместе; + неполные `refresh` без отметки журнала не получают.** Обоснование: анти-цель «молча теряет» (находки рецензента: + новый проект не должен обнулять отметку прежних; хвост пакета не должен тянуть стартовую загрузку). Цена ошибки: + при расхождении часов > 15 минут — пропуски; агенту два режима в одном `refresh`. +4. 2026-10-22 — **стартовая загрузка — JQL `sprint in openSprints() AND statusCategory != Done`, журнал целиком, + разово.** Обоснование: формулировка владельца; активный спринт в зеркале не отличим от будущего; `closed_statuses` + со статусом, которого нет в Jira, дал бы 400 навсегда. Цена ошибки: статус с категорией не Done, который владелец + считает закрытым, попадёт в стартовую загрузку (лишний журнал); канбан-проекты без стартовой истории; упавшая + стартовая загрузка не повторяется (видно в `backfill_error`). +5. 2026-10-22 — **ошибка Jira или хаба (не 2xx) при чтении журнала не роняет `refresh` и не повторяет команду** + (`done` с `history.error`, отметка не сдвигается). Обоснование: зеркало важнее ленты; повтор команды перекачал бы + состояние ради журнала; следующий прогон дочитает. Цена ошибки: при разовом 5xx лента отстаёт на один интервал + автообновления. +6. 2026-10-22 — **логическое имя поля вычисляет хаб, агент шлёт `field_id` и `field_name`.** Обоснование: соответствие id + полей — в настройках хаба. Цена ошибки: если Jira не отдаёт `fieldId` и агент не нашёл id по имени — плановые даты + попадут в `other`; видно на живой проверке. +7. 2026-10-22 — **комментарии в ленте — из `issue_comment` по новой колонке `created_at` (UTC), а не событиями + журнала.** Обоснование: комментариев нет в журнале Jira; они уже приходят с автором и временем. Цена ошибки: + удалённый в Jira комментарий пропадает из ленты. +8. 2026-10-22 — **лента проекта — только свои задачи, без соседей.** Обоснование: соседи — задачи других меток, в ленте + проекта это шум. Цена ошибки: изменение блокирующей задачи другого проекта не видно в ленте (видно через `key`). +9. 2026-10-22 — **MCP `changes` по умолчанию — с начала сегодняшнего дня по местному времени.** Обоснование: «что + закрылось сегодня» — первый сценарий владельца. Цена ошибки: лишний параметр в вопросе «за 2 часа» (`hours: 2`). +10. 2026-10-22 — **история задачи — полем `changes` в `GET /issues/{key}`, а не отдельным маршрутом.** Обоснование: так + уже отдаются комментарии; карточка и MCP `issue` получают всё одним запросом. Цена ошибки: тяжелее ответ карточки + у задач с длинной историей. +11. 2026-10-22 — **событие «задача создана» не входит в ленту.** Обоснование: владелец его не называл, в журнале Jira его + нет. Цена ошибки: новые задачи дня не видны в ленте; добавляется по `issue.created` без агента. +12. 2026-10-22 — **журнал приёмом `POST /agent/history` пачками по 500 элементов; ответ `{accepted, duplicates}`.** + Обоснование: не менять контракт `/agent/state`; размер пачки — по опыту реле с состоянием. Цена ошибки: пачки + описаний тяжелее пачек состояния — при сбоях реле уменьшить. +13. 2026-10-22 — **неполный журнал задачи не держит отметку; ключи — в `result.history.incomplete` и в логе.** + Обоснование: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда. Цена ошибки: потерянные + старые записи у задач с очень длинной историей; видно в результате команды и на живой проверке. +14. 2026-10-22 — **агент отбрасывает элементы, которые хаб отверг бы** (пустые `created`, `field_name`, нет `id`), с + записью в лог. Обоснование: один кривой элемент иначе давал бы 422 на пачку в каждом прогоне. Цена ошибки: такой + элемент не попадёт в ленту. +15. 2026-10-22 — **элемент ленты несёт и строки, и сырые значения (`from_value`/`to_value`); MCP показывает даты по + значению.** Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее. +16. 2026-10-22 — **`sync_log` показывает, принят ли журнал прогона (`history_at`).** Обоснование: дизайн обещал видимость + ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP. diff --git a/openspec/changes/change-history/proposal.md b/openspec/changes/change-history/proposal.md new file mode 100644 index 0000000..af3a7e6 --- /dev/null +++ b/openspec/changes/change-history/proposal.md @@ -0,0 +1,50 @@ +## Why + +Зеркало хаба хранит только текущее состояние задачи: при каждом обновлении строка `issue` перезаписывается, прежние +значения теряются. Владелец хочет быстро видеть, **что произошло с задачами за последние часы или за сегодня** — что +закрылось, у чего поменялось описание, где появился комментарий, у чего сдвинулись сроки, — и **как менялась конкретная +задача**. + +Решения владельца 22.10.2026 (brainstorming, jira-hub#70, jira-agent#39): +- **источник — журнал изменений Jira (changelog)**, который тянет агент: точное время и автор каждого изменения, а не + разница снимков хаба; +- **события ленты**: смена статуса (в том числе закрытие), правка описания, новый комментарий, изменение сроков; +- **регулярно — без отдельного «слепого» запроса**: агент из уже выгруженных задач отбирает изменившиеся с прошлого + раза (по `updated`) и только по ним тянет журнал; +- **стартовая загрузка — только незакрытые задачи текущего спринта**, не всё зеркало; +- **где смотреть**: MCP (спросить Claude «что изменилось за 2 часа») и, позже, веб-приложение — страница «Лента» и + история в карточке задачи; +- **веб-приложение в этом change не трогаем**: идёт редизайн (#69), веб-часть — отдельной задачей после него. + +## What Changes + +- **Журнал изменений в хабе.** Новая таблица `issue_change` (схема v5): одна строка — одно изменённое поле в одной + записи журнала Jira (id записи, порядковый номер поля в ней, ключ задачи, время, автор, поле, было/стало). Повторно + присланные записи отбрасываются по id, поэтому перекрытие окон безопасно. Время комментариев приводится к UTC + (`issue_comment.created_at`), чтобы ленту можно было резать по времени. +- **Отметка «с какого момента».** Хаб при выдаче `refresh` дописывает параметр `history`: `{since}` — время начала + последнего полного прогона, по которому журнал принят, минус запас; если такого прогона нет — `{jql}` стартовой + загрузки (незакрытые задачи открытых спринтов из базовой JQL). +- **Агент v13** (отдельный PR в jira-agent): после выгрузки состояния отбирает задачи с `updated >= since` и одним + `search` с `expand=changelog` на 100 ключей получает их журнал; в режиме стартовой загрузки — отдельный `search` по + `history.jql`. Записи отправляет хабу новым маршрутом `POST /agent/history`; итог — в результате `refresh`. +- **Лента в API.** `GET /changes` — события за период (журнал + новые комментарии), с разрезом по проекту, фильтром по + видам событий и по задаче. +- **MCP.** Новый инструмент `changes` (лента за период: по умолчанию — с начала сегодняшнего дня; `hours`, `since`, + `until`, `kinds`, `key`); `issue` показывает 5 последних изменений задачи. + +## Non-Goals + +- Веб-приложение: страница «Лента», история в карточке задачи — отдельной задачей после редизайна (#69). +- Событие «задача создана» в ленте — в журнале Jira его нет; можно добавить позже по `issue.created`. +- Правка и удаление комментария — их нет в журнале Jira; в ленте только новые комментарии, пока они есть в Jira. +- История до стартовой загрузки по задачам вне открытых спринтов и по закрытым задачам. +- Срок хранения, чистка журнала — пока храним всё. +- Отдельный запрос журнала без `refresh` или своя периодичность — журнал приходит только вместе с обновлением зеркала. + +## Anti-Goals + +- Лента молча теряет изменения: например, агент не смог дочитать журнал, а отметка «с какого момента» всё равно + сдвинулась вперёд. +- Каждое автообновление тянет журнал всех задач зеркала и заметно замедляет обновление или упирается в лимиты Jira. +- Старый агент (≤ v12) или сбой чтения журнала ломает обновление зеркала. diff --git a/openspec/changes/change-history/specs/agent/spec.md b/openspec/changes/change-history/specs/agent/spec.md new file mode 100644 index 0000000..0fbad25 --- /dev/null +++ b/openspec/changes/change-history/specs/agent/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: Журнал изменений +Если в параметрах `refresh` есть `history`, агент (с v13) SHALL после отправки всех пачек состояния прочитать журнал +изменений (changelog) Jira и отправить его хабу. `history` может содержать `since`, `jql` или оба: +- `since` (регулярная часть) — из задач, выгруженных этим `refresh` (база и соседи), агент SHALL отобрать те, у которых + Jira `updated` не раньше `since` (сравнение моментов времени с учётом смещения), и прочитать их журнал запросом + `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять + записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет. +- `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog` + (постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Задача, уже прочитанная регулярной частью, + повторно не читается. + +Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал +`GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ +и «получено N из M» и добавить ключ в `history.incomplete` результата. + +Элемент для хаба — одно поле записи журнала: `{history_id, seq, key, created, author, field_id, field_name, from, +from_string, to, to_string}`: `history_id` — id записи строкой; `seq` — номер элемента в `items` записи, с 0; `created` — +время записи как есть; `author` — отображаемое имя автора, иначе логин, иначе `null`; `field_name` — `field` элемента; +`field_id` — `fieldId` элемента, а если его нет: для `fieldtype = custom` — id поля, найденный по имени в +`GET /rest/api/2/field` (не больше одного такого вызова за `refresh`), для прочих — `field`; не найден — `null`; `from`, +`fromString`, `to`, `toString` — как есть, нестроковое значение — строкой. Запись без `id` или с пустым `created` и +элемент с пустым `field` агент SHALL отбросить с записью в лог (ключ задачи). Элементы SHALL отправляться +`POST /agent/history` пачками до 500; повторы при отправке — как у пачек состояния. + +Результат `refresh` SHALL содержать `history = {issues: <задач с хотя бы одним элементом>, changes: <элементов +отправлено>, incomplete: [<ключи>]}`. Ответ не 2xx (`HttpError`) от Jira или хаба MUST NOT переводить `refresh` в +`failed` и MUST NOT вызывать повтор команды: агент SHALL записать ошибку в лог и вернуть `done`, добавив в `history` +текст ошибки (у ошибки хаба — префикс «хаб: ») — `error` при ошибке регулярной части или отправки элементов, +`backfill_error` при ошибке `search` стартовой загрузки (регулярная часть при этом выполняется). Обрыв связи с Jira или +хабом (`ConnectionError`) на шаге журнала обрабатывается, как в остальном `refresh`. Без `history` в параметрах агент +журнал не читает, ключа `history` в результате нет. + +#### Scenario: Ничего не менялось +- **WHEN** `refresh {jql: base, history: {since: "2026-10-22T08:45:00Z"}}`, у всех 180 задач `updated` раньше `since` +- **THEN** вызовов `search` с `expand = changelog` нет; результат `history = {issues: 0, changes: 0, incomplete: []}` + +#### Scenario: Две задачи изменились +- **WHEN** тот же `refresh`, у X и Y `updated` позже `since`; у X в журнале записи от вчера и от 09:10 UTC (смена статуса и + резолюции), у Y — от 09:30 UTC (правка описания) +- **THEN** один `search` `key in (X,Y)` с `expand = changelog`; хабу отправлены 3 элемента (у X — `seq` 0 и 1 одной записи, + вчерашняя запись не отправлена); результат `history = {issues: 2, changes: 3, incomplete: []}` + +#### Scenario: Стартовая загрузка +- **WHEN** `refresh {jql: base, history: {jql: "(base) AND sprint in openSprints() AND statusCategory != Done"}}`, по ней + 12 задач +- **THEN** `search` по `history.jql` с `expand = changelog`; журнал 12 задач отправлен целиком + +#### Scenario: Плановая дата без fieldId +- **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим + именем id `customfield_10201` +- **THEN** у элемента `field_id = "customfield_10201"`; `GET /field` вызван один раз за `refresh` + +#### Scenario: Ошибка Jira при чтении журнала +- **WHEN** `search` с `expand = changelog` для регулярной части ответил 500 +- **THEN** состояние в хабе обновлено; `refresh` — `done` с `history.error`, повтора команды нет; в логе — ошибка + +#### Scenario: Ошибка стартовой загрузки +- **WHEN** `history = {since: …, jql: …}`, `search` по `history.jql` ответил 400, у X `updated` позже `since` +- **THEN** журнал X отправлен; `refresh` — `done`, в `history` — `backfill_error` с текстом Jira, `error` нет + +#### Scenario: Неполный журнал +- **WHEN** у X `changelog.total = 250`, и `search`, и `GET /issue/X?expand=changelog` вернули 100 записей +- **THEN** отправлены 100 записей X; в логе «X: получено 100 из 250»; `history.incomplete = ["X"]` + +#### Scenario: Хаб без журнала +- **WHEN** `refresh {jql: base}` без `history` +- **THEN** журнал не читается; в результате нет ключа `history` diff --git a/openspec/changes/change-history/specs/commands/spec.md b/openspec/changes/change-history/specs/commands/spec.md new file mode 100644 index 0000000..e798472 --- /dev/null +++ b/openspec/changes/change-history/specs/commands/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Журнал изменений в refresh +Отметка журнала — `started_at` последнего `sync_run` с `history_at IS NOT NULL` (любой `query`). JQL стартовой загрузки +— `(<базовая JQL>) AND sprint in openSprints() AND statusCategory != Done`. Хаб SHALL при каждой выдаче `refresh` +(первой и повторной) дописывать в параметры `history` (в базе `params` без него, как `boards`): +- полный прогон (`refresh` по базовой JQL), отметка есть — `{since: <отметка − 15 минут, ISO-8601 UTC с Z>}`; если + `query` прогона-отметки не равна текущей базовой JQL — также `jql: `; +- полный прогон, отметки нет — `{jql: }`; +- `refresh {keys}` и `refresh` по небазовой JQL — `{since: …}`, если отметка есть; иначе `history` не дописывается; +- без базовой JQL (нет проектов) — `history` не дописывается. + +Результат `refresh` MAY содержать `history` (см. `agent`, «Журнал изменений»); хаб SHALL принимать результат и без +него. Отметку (`sync_run.history_at`) хаб SHALL проставлять по правилам `data-model`, «Отметка журнала в журнале +синхронизаций». + +#### Scenario: Первый refresh после выкатки +- **WHEN** проект `AWPCA`, прогонов с `history_at` нет; выдаётся полный `refresh` +- **THEN** в выданных параметрах `history = {jql: '(project = AURORA AND labels in ("AWPCA")) AND sprint in + openSprints() AND statusCategory != Done'}`; в базе `params` без `history` + +#### Scenario: Регулярный refresh +- **WHEN** последний полный прогон с `history_at` начат в `2026-10-22T09:00:00+00:00` по текущей базовой JQL; выдаётся + автообновление +- **THEN** `history = {since: "2026-10-22T08:45:00Z"}` + +#### Scenario: Журнал не дочитан +- **WHEN** прогон в 10:00 завершился `done` с `history.error`, прогон в 09:00 — с принятым журналом; выдаётся следующий + полный `refresh` +- **THEN** `history.since` — от прогона 09:00 + +#### Scenario: Новый проект +- **WHEN** прогон с `history_at` в 09:00 по JQL только с `AWPCA`; затем создан проект `STOCKS_DRRK`; выдаётся полный + `refresh` +- **THEN** `history = {since: "…T08:45:00Z", jql: <стартовая по новой базовой JQL>}` + +#### Scenario: Хвост пакета +- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, отметка есть +- **THEN** `history = {since: …}`; по завершении `done` с `result.history` у его `sync_run` `history_at = NULL` + +#### Scenario: Хвост пакета до первой отметки +- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, прогонов с `history_at` нет +- **THEN** в выданных параметрах нет `history` diff --git a/openspec/changes/change-history/specs/data-model/spec.md b/openspec/changes/change-history/specs/data-model/spec.md new file mode 100644 index 0000000..8f8e330 --- /dev/null +++ b/openspec/changes/change-history/specs/data-model/spec.md @@ -0,0 +1,127 @@ +## MODIFIED Requirements + +### Requirement: Схема базы хаба +База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_note`, +`batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при +старте по номеру версии в `setting.schema_version` (текущая — 5). Миграция существующей базы SHALL выполняться в одной +транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в +`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни +открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их +при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и +дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени +Jira как есть (`issue_comment.created`/`updated`, `issue_change.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`. + +#### 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 = 5` + +#### Scenario: Миграция базы v2 +- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки +- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 5` + +#### 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 = 5` + +#### 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`; `schema_version = 5` + +#### 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` + + +## ADDED Requirements + +### 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` diff --git a/openspec/changes/change-history/specs/hub-api/spec.md b/openspec/changes/change-history/specs/hub-api/spec.md new file mode 100644 index 0000000..cadacb8 --- /dev/null +++ b/openspec/changes/change-history/specs/hub-api/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Приём журнала изменений +Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом +`{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from, +from_string, to, to_string}`. До записи хаб SHALL проверить форму всей пачки: `changes` — список объектов; +`history_id`, `key`, `created`, `field_name` — непустые строки; `seq` — целое ≥ 0; `author`, `field_id`, `from`, +`from_string`, `to`, `to_string` — строка или `null`. Нарушение — 422 `bad_history` с номером элемента и именем поля, +пачка не применяется. Принятая пачка записывается в `issue_change` в одной транзакции (см. `data-model`, «Журнал +изменений задач»); ответ — `{accepted, duplicates}` (записано новых строк, отброшено повторов). Задача элемента может +отсутствовать в зеркале — элемент всё равно записывается. + +#### Scenario: Пачка журнала +- **WHEN** `POST /agent/history` с тремя элементами, один из них уже есть в `issue_change` +- **THEN** 200 `{accepted: 2, duplicates: 1}` + +#### Scenario: Неверный элемент +- **WHEN** `POST /agent/history` с двумя элементами, у второго `seq = "0"` +- **THEN** 422 `bad_history` с номером элемента 1 и полем `seq`; ни один элемент не записан + +#### Scenario: Клиентский токен +- **WHEN** `POST /agent/history` клиентским токеном +- **THEN** 401 + +### Requirement: Лента изменений +Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами: +- `since`, `until` — ISO-8601 со смещением или `Z`; без `until` — текущее время; без `since` — `until` минус 24 часа; +- `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404; +- `key` — события одной задачи (без ограничения проектом); +- `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию — + `status,description,dates,comment`; +- `limit` — по умолчанию 500, не больше 2000. + +События: строки `issue_change` с `at` в `[since, until]` — вид `status` (поле `status`), `description` (поле +`description`), `dates` (поля `duedate`, `planned_start`, `planned_end`), `other` (прочие поля); и комментарии +`issue_comment` с `created_at` в `[since, until]` — вид `comment`. Ответ — `{since, until, truncated, changes: [...]}`, +элемент — `{at, key, summary, status, kind, field, author, from, to, from_value, to_value}`: `summary` и `status` — +текущие из зеркала (`null`, если задачи в зеркале нет); `from`/`to` — `from_string`/`to_string` строки журнала, а если +строка `null` — `from_value`/`to_value`; `from_value`/`to_value` — сырые значения строки журнала; у комментария +`field = "comment"`, `from = null`, `to` — текст комментария, `from_value = to_value = null`. Порядок — по `at` +по убыванию, затем `key`, `history_id`, `seq` (комментарии — `comment_id`). Событий больше `limit` — отдаются первые +`limit` по этому порядку и `truncated = true`. Неверное время, `since` позже `until`, неизвестный вид, `limit` вне +1..2000 — 422 `bad_request` с именем параметра. + +#### Scenario: Что закрылось за два часа +- **WHEN** в 12:00 UTC `GET /changes?project=AWPCA&since=2026-10-22T10:00:00Z&kinds=status`; в журнале — X (метка + `AWPCA`) «В работе» → «Готово» в 11:15 UTC и Y (метка `STOCKS_DRRK`) в 11:20 UTC +- **THEN** одно событие: X, `kind = status`, `from = "В работе"`, `to = "Готово"`, автор и `at` из журнала + +#### Scenario: Комментарий в ленте +- **WHEN** у X комментарий с `created = "2026-10-22T13:30:00.000+0300"` (10:30 UTC), запрос с `since` 10:00 UTC +- **THEN** в ленте событие `kind = comment`, `at = "2026-10-22T10:30:00+00:00"`, `to` — текст комментария + +#### Scenario: Прочие поля по запросу +- **WHEN** в журнале X сменился исполнитель; запрос без `kinds`, затем с `kinds=all` +- **THEN** в первом ответе события нет; во втором — `kind = other`, `field = "assignee"` + +#### Scenario: Сосед проекта не в ленте +- **WHEN** Z без метки `AWPCA` связана с задачей `AWPCA`; в журнале Z сменился статус; `GET /changes?project=AWPCA` +- **THEN** события Z нет; `GET /changes?key=Z` его отдаёт + +#### Scenario: Неверный период +- **WHEN** `GET /changes?since=вчера` +- **THEN** 422 `bad_request` с параметром `since` + +### Requirement: Журнал в журнале синхронизаций +`GET /sync-runs` SHALL отдавать у каждого прогона также `history_at` (`null` — журнал по прогону не принят). + +#### Scenario: Прогон с журналом +- **WHEN** полный прогон завершился `done` с принятым журналом; `GET /sync-runs` +- **THEN** у прогона `history_at` — время приёма; у хвостового `refresh {keys}` — `null` + +### Requirement: История задачи в карточке +`GET /issues/{key}` SHALL отдавать также `changes` — все строки `issue_change` задачи (все виды, включая `other`) в +формате элемента ленты без `summary` и `status`, по `at` по убыванию (строки с `at = NULL` — в конце); журнала нет — +`changes = []`. + +#### Scenario: Задача с историей +- **WHEN** у X в журнале смена статуса вчера и правка описания сегодня; `GET /issues/X` +- **THEN** `changes` из двух элементов: первым — правка описания (`kind = description`), вторым — смена статуса diff --git a/openspec/changes/change-history/specs/mcp/spec.md b/openspec/changes/change-history/specs/mcp/spec.md new file mode 100644 index 0000000..ec3982d --- /dev/null +++ b/openspec/changes/change-history/specs/mcp/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Лента изменений в MCP +Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`, +`until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `since` — с начала +текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. В сессии, привязанной к +проекту P, без `key` — `project = P`; с `key` — без проекта. Без привязки и без `key` — ответ «сессия не привязана к +проекту: запустите MCP с --project <метка> (список — projects)», в хаб не ходить; с `key` — работает. + +Вид ответа: первая строка — период по местному времени («изменения с 22.10 00:00 по 22.10 14:05»); затем события, +сгруппированные по задаче; задачи — по времени последнего события, новые сверху; у задачи строка «КЛЮЧ тема (до 80 +символов) — текущий статус», под ней события по времени, новые сверху, каждое — «ЧЧ:ММ автор: <что>», с датой «ДД.ММ» +перед временем, если событие не в текущих сутках. `<что>`: статус — «статус «было» → «стало»»; описание — «описание +изменено»; срок и плановые даты — «срок / плановое начало / плановое окончание ДД.ММ → ДД.ММ» (дата — из начала +`from_value`/`to_value` вида `ГГГГ-ММ-ДД`, иначе `from`/`to` как есть; пустое значение — «—»); +комментарий — «комментарий: » и первые 200 символов текста с «…»; прочие поля — «<поле>: «было» → «стало»» (значения до +80 символов с «…»). Событий нет — «за период изменений нет». `truncated` — последняя строка «показаны последние N +событий, сузьте период». Ошибка 422 хаба — её текст. + +Инструмент `issue` SHALL после комментариев выводить «изменения (N):» и 5 последних элементов `changes` задачи в том же +виде событий (с датой), «ещё M», если их больше 5; при `changes = []` — строки нет. + +Инструмент `sync_log` SHALL выводить у каждого прогона «журнал: принят ДД.ММ ЧЧ:ММ» (по `history_at`, местное время) +или «журнал: нет». + +#### Scenario: Что закрылось сегодня +- **WHEN** сессия `AWPCA`, `changes {kinds: "status"}` в 14:05; сегодня X переведена в «Готово» в 11:15 +- **THEN** ответ содержит строку X с темой и статусом «Готово» и «11:15 Петров Пётр: статус «В работе» → «Готово»» + +#### Scenario: За два часа +- **WHEN** сессия `AWPCA`, `changes {hours: 2}` +- **THEN** запрос `GET /changes?project=AWPCA&since=<сейчас − 2 ч>`; в ответе события статусов, описаний, сроков и + комментариев + +#### Scenario: Без привязки +- **WHEN** MCP без `--project`, `changes {}` +- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /changes` нет + +#### Scenario: Пустой период +- **WHEN** `changes {hours: 1}`, событий нет +- **THEN** ответ — строка периода и «за период изменений нет» + +#### Scenario: История в карточке +- **WHEN** `issue {key: X}`, у X 7 элементов журнала +- **THEN** ответ содержит «изменения (7):», 5 последних событий и «ещё 2»