OpenSpec change-history: журнал изменений задач (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fa91e49d2d
commit
5a6cdecc8e
2
openspec/changes/change-history/.openspec.yaml
Normal file
2
openspec/changes/change-history/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
218
openspec/changes/change-history/design.md
Normal file
218
openspec/changes/change-history/design.md
Normal file
@ -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 = (<base_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.
|
||||
50
openspec/changes/change-history/proposal.md
Normal file
50
openspec/changes/change-history/proposal.md
Normal file
@ -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) или сбой чтения журнала ломает обновление зеркала.
|
||||
69
openspec/changes/change-history/specs/agent/spec.md
Normal file
69
openspec/changes/change-history/specs/agent/spec.md
Normal file
@ -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`
|
||||
43
openspec/changes/change-history/specs/commands/spec.md
Normal file
43
openspec/changes/change-history/specs/commands/spec.md
Normal file
@ -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 стартовой загрузки>`;
|
||||
- полный прогон, отметки нет — `{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`
|
||||
127
openspec/changes/change-history/specs/data-model/spec.md
Normal file
127
openspec/changes/change-history/specs/data-model/spec.md
Normal file
@ -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 = <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`.
|
||||
|
||||
#### 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`
|
||||
80
openspec/changes/change-history/specs/hub-api/spec.md
Normal file
80
openspec/changes/change-history/specs/hub-api/spec.md
Normal file
@ -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`), вторым — смена статуса
|
||||
45
openspec/changes/change-history/specs/mcp/spec.md
Normal file
45
openspec/changes/change-history/specs/mcp/spec.md
Normal file
@ -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»
|
||||
Loading…
x
Reference in New Issue
Block a user