From 5a6cdecc8e5527bf3e90a11a044afac5dc6b42bb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 09:10:06 +0400 Subject: [PATCH 1/9] =?UTF-8?q?OpenSpec=20change-history:=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/change-history/.openspec.yaml | 2 + openspec/changes/change-history/design.md | 218 ++++++++++++++++++ openspec/changes/change-history/proposal.md | 50 ++++ .../change-history/specs/agent/spec.md | 69 ++++++ .../change-history/specs/commands/spec.md | 43 ++++ .../change-history/specs/data-model/spec.md | 127 ++++++++++ .../change-history/specs/hub-api/spec.md | 80 +++++++ .../changes/change-history/specs/mcp/spec.md | 45 ++++ 8 files changed, 634 insertions(+) create mode 100644 openspec/changes/change-history/.openspec.yaml create mode 100644 openspec/changes/change-history/design.md create mode 100644 openspec/changes/change-history/proposal.md create mode 100644 openspec/changes/change-history/specs/agent/spec.md create mode 100644 openspec/changes/change-history/specs/commands/spec.md create mode 100644 openspec/changes/change-history/specs/data-model/spec.md create mode 100644 openspec/changes/change-history/specs/hub-api/spec.md create mode 100644 openspec/changes/change-history/specs/mcp/spec.md 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» -- 2.47.2 From ba7ac9fb41ae7b86cc6595b6b3119172b291f93c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:04:35 +0400 Subject: [PATCH 2/9] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87,=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-22-change-history-hub.md | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-change-history-hub.md diff --git a/docs/superpowers/plans/2026-10-22-change-history-hub.md b/docs/superpowers/plans/2026-10-22-change-history-hub.md new file mode 100644 index 0000000..5d407ea --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-change-history-hub.md @@ -0,0 +1,295 @@ +# Журнал изменений задач (хаб) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** хаб хранит журнал изменений задач Jira, присланный агентом, выдаёт агенту отметку «с какого момента» в +`refresh`, отдаёт ленту событий (`GET /changes`), историю задачи (`GET /issues/{key}.changes`) и MCP-инструмент `changes`. + +**Architecture:** новый модуль `history` (рядом с `mirror`, ниже `queue`/`api`): проверка и запись пачек журнала, +логическое имя поля, параметр `history` для выдачи `refresh`, лента и история задачи. Приведение времени Jira к UTC — +`db.jira_utc` (им пользуются и `mirror`, и `history`, и миграция). `queue.deliver_params` дописывает `history`, +`queue.report_result` ставит `sync_run.history_at`. MCP ходит только через `client`. + +**Tech Stack:** Python 3.13, stdlib `http.server`, SQLite, pytest, пакет `mcp` (FastMCP). + +**Spec:** `openspec/changes/change-history/` — `proposal.md`, `design.md` (решения 1–17, 11a; «Decisions made without +discussion» 1–16), `specs/{data-model,hub-api,commands,mcp,agent}/spec.md`. Спека `agent` — только контракт для хабовой +стороны (агент v13 — отдельный план в `jira-agent`, `docs/superpowers/plans/2026-10-22-change-history-agent.md`). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/history`, ветка `feat/70-change-history` +(aep/jira-hub#70). Все команды — из корня worktree; основной checkout `jira-hub` не трогать (guard-main). Перед правкой +`src/` — файл `.bounded` не нужен: план есть (require-plan видит план в `docs/superpowers/plans/`). + +## Global Constraints + +- Схема базы — версия 5: таблица `issue_change(history_id TEXT NOT NULL, seq INTEGER NOT NULL, key TEXT NOT NULL, at TEXT, created TEXT NOT NULL, author TEXT, field TEXT NOT NULL, field_id TEXT, field_name TEXT NOT NULL, from_value TEXT, from_string TEXT, to_value TEXT, to_string TEXT, fetched_at TEXT NOT NULL, PRIMARY KEY (history_id, seq))`, индексы `ix_issue_change_at(at)`, `ix_issue_change_key(key, at)`; колонки `issue_comment.created_at TEXT`, `sync_run.history_at TEXT`. +- Время в базе — `db.now()`-формат (`2026-10-22T10:05:11+00:00`); `history.since` для агента — `YYYY-MM-DDTHH:MM:SSZ`. +- Запас отметки — 15 минут (`history.SINCE_MARGIN = timedelta(minutes=15)`). +- JQL стартовой загрузки — ровно `f"({base_jql}) AND sprint in openSprints() AND statusCategory != Done"`. +- Виды ленты: `status` ← поле `status`; `description` ← `description`; `dates` ← `duedate`, `planned_start`, `planned_end`; `comment` ← `issue_comment`; `other` ← прочее. По умолчанию — все, кроме `other`; `kinds=all` — все пять. +- `GET /changes`: `limit` по умолчанию 500, допустимо 1..2000; без `since` — `until − 24 ч`; без `until` — сейчас. +- Ошибки: 422 `bad_history` (приём журнала), 422 `bad_request` (параметры ленты), 404 `not_found` (проект). +- MCP: `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history`, ходит только через `client`. +- Новых зависимостей нет. +- Язык комментариев, сообщений об ошибках и коммитов — русский, идентификаторы — английские. Каждый коммит — с `(#70)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. +- Зелёным считается только весь набор: `uv run pytest` (фронт не меняется; `make check` — в финальной задаче). +- Любое решение исполнителя, не записанное в спеке или плане, дописывается в `openspec/changes/change-history/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки) с маркером `R-n`. Находки ревью задачи n — `T.`. + +## Review Focus + +1. Время Jira в неожиданной форме (`+03:00`, `Z`, без дробной части, без смещения, мусор, `None`) — `db.jira_utc` не падает: разбирает или возвращает `None`; комментарий с мусорным `created` записывается с `created_at = NULL` и в ленту не попадает (тесты в Task 1). +2. Агент v12 присылает результат `refresh` без `history`, или `history` не словарь, или `history.error` пустая строка — отчёт принимается, `history_at` не ставится только при наличии непустого `error` или отсутствии/не-словаре `history` (тесты в Task 3). +3. Повторная выдача `refresh` (довыдача после потери отчёта) — `history` дописывается заново по текущей отметке; в базе `params` без `history` (тест в Task 3). +4. Лента по задаче, которой нет в зеркале (журнал пришёл раньше состояния или задача ушла из выборки) — событие отдаётся с `summary = null`, `status = null`, не теряется и не роняет запрос (тест в Task 4). +5. MCP `changes` с `hours` и `since` вместе, `hours <= 0`, мусорный `since` — внятный текст, а не трассировка (тесты в Task 5). + +--- + +### Task 1: Схема v5, `db.jira_utc`, время комментариев в UTC + +**Модель исполнителя:** sonnet (миграция с Python-шагом и правка `apply_state`). + +**Files:** +- Modify: `src/jira_hub/db.py` (`SCHEMA`, `SCHEMA_VERSION = 5`, `MIGRATIONS[4]`, `jira_utc`) +- Modify: `src/jira_hub/mirror.py` (`apply_state`: `issue_comment.created_at`) +- Test: `tests/test_db.py`, `tests/test_mirror.py`; поправить ожидания `schema_version == "4"` → `"5"` в `tests/test_db.py` + +**Interfaces:** +- Produces: `db.jira_utc(value: str | None) -> str | None` — ISO-8601 UTC в формате `db.now()` или `None`. Принимает `+0300`, `+03:00`, `Z`, с дробной частью секунд или без; без смещения — UTC; иначе `None`. Дробная часть отбрасывается. +- Produces: `MIGRATIONS[4] = ["ALTER TABLE issue_comment ADD COLUMN created_at TEXT", "ALTER TABLE sync_run ADD COLUMN history_at TEXT", _fill_comment_utc]`, где `_fill_comment_utc(conn)` проставляет `created_at = jira_utc(created)` у всех строк `issue_comment`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_db.py +@pytest.mark.parametrize("raw, want", [ + ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11Z", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"), + ("вчера", None), ("", None), (None, None), +]) +def test_jira_utc(raw, want): + assert db.jira_utc(raw) == want + +def test_migrates_v4(tmp_path): + # база v4: создать текущей схемой, удалить колонки v5 нельзя — строить как _v3_database: connect, затем + # DROP TABLE issue_change; пересоздать issue_comment и sync_run без новых колонок; schema_version = 4; + # комментарий X с created "2026-10-22T13:05:11.000+0300"; sync_run полного прогона + ... + assert schema_version == "5" + assert comment["created_at"] == "2026-10-22T10:05:11+00:00" and comment["created"] == "2026-10-22T13:05:11.000+0300" + assert run["history_at"] is None + assert conn.execute("SELECT COUNT(*) FROM issue_change").fetchone()[0] == 0 + assert any(p.name.endswith("schema-v4.sqlite") for p in (tmp_path / "backups").iterdir()) + +# tests/test_mirror.py +def test_apply_state_comment_created_at(conn): + mirror.apply_state(conn, [{"key": "X", "comments": [ + {"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"}, + {"id": "2", "body": "b", "created": "мусор"}]}], []) + rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")} + assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_db.py tests/test_mirror.py -q` +Expected: FAIL (`jira_utc` нет, `schema_version` 4, колонки `created_at` нет) + +- [ ] **Step 3: Implement** схему, миграцию, `jira_utc` (разбор: заменить `Z` на `+00:00`, вставить двоеточие в смещение `±HHMM`, `datetime.fromisoformat`, `astimezone(timezone.utc)`, `replace(microsecond=0)`, `ValueError`/`TypeError` → `None`), в `apply_state` вставлять `created_at = db.jira_utc(c.get("created"))`. Поправить старые ожидания `"4"` → `"5"`. + +- [ ] **Step 4: Run the full suite** + +Run: `uv run pytest -q` +Expected: всё зелёное + +- [ ] **Step 5: Commit** — `Схема v5: issue_change, время комментариев в UTC (#70)` + +--- + +### Task 2: Модуль `history` — приём журнала, `POST /agent/history` + +**Модель исполнителя:** sonnet. + +**Files:** +- Create: `src/jira_hub/history.py` +- Modify: `src/jira_hub/api.py` (обработчик `h_agent_history`, маршрут `("POST", r"^/agent/history$", h_agent_history, "agent")`) +- Test: `tests/test_history.py` (новый), `tests/test_api.py` + +**Interfaces:** +- Consumes: `db.jira_utc`, `db.now`, `db.transaction`, `settings.get(conn, "field_ids")`, `settings.get(conn, "epic_link")`. +- Produces (`src/jira_hub/history.py`): + - `BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution")`. + - `check_changes(changes) -> None` — `ValueError(f"changes[{n}].{field} — …")` по правилам спеки `hub-api` «Приём журнала изменений»; `changes` не список — `ValueError("changes — список")`. + - `logical_field(item: dict, field_ids: dict, epic_link: dict) -> str` — по спеке `data-model` «Журнал изменений задач». + - `record_changes(conn, changes: list[dict]) -> dict` — `{"accepted": int, "duplicates": int}`; `INSERT OR IGNORE`, в одной транзакции, `at = db.jira_utc(created)`, `fetched_at = db.now()`; `from`/`to` элемента → `from_value`/`to_value`. +- Produces (`api`): `POST /agent/history` → `history.check_changes` (422 `bad_history` с текстом `ValueError`), затем `history.record_changes` → 200 `{accepted, duplicates}`. + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/test_history.py +ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", + "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", + "to_string": "Готово"} + +def test_record_dedup(conn): + assert history.record_changes(conn, [ITEM]) == {"accepted": 1, "duplicates": 0} + assert history.record_changes(conn, [ITEM, {**ITEM, "seq": 1, "field_id": None, "field_name": "resolution"}]) == \ + {"accepted": 1, "duplicates": 1} + row = conn.execute("SELECT * FROM issue_change WHERE seq = 0").fetchone() + assert row["at"] == "2026-10-22T06:00:00+00:00" and row["field"] == "status" and row["from_value"] == "3" + +@pytest.mark.parametrize("item, field_ids, epic, want", [ + ({"field_id": "customfield_10201", "field_name": "Плановое окончание"}, {"planned_end": "customfield_10201"}, {}, "planned_end"), + ({"field_id": None, "field_name": "duedate"}, {}, {}, "duedate"), + ({"field_id": None, "field_name": "Fix Version"}, {}, {}, "Fix Version"), + ({"field_id": "customfield_1", "field_name": "Epic Link"}, {}, {"field_id": "customfield_1"}, "epic_link"), +]) +def test_logical_field(item, field_ids, epic, want): + assert history.logical_field(item, field_ids, epic) == want + +@pytest.mark.parametrize("bad, field", [({"seq": "0"}, "seq"), ({"seq": -1}, "seq"), ({"key": ""}, "key"), + ({"created": None}, "created"), ({"to_string": 5}, "to_string")]) +def test_check_changes_rejects(bad, field): + with pytest.raises(ValueError, match=rf"changes\[1\]\.{field}"): + history.check_changes([ITEM, {**ITEM, **bad}]) + +# tests/test_api.py +def test_agent_history_route(...): # агентским токеном: 200 {accepted: 2, duplicates: 1}; клиентским — 401; + ... # пачка с seq="0" у второго — 422 bad_history, в issue_change ничего не прибавилось +``` + +- [ ] **Step 2: Run** `uv run pytest tests/test_history.py tests/test_api.py -q` — FAIL (модуля нет) +- [ ] **Step 3: Implement** `history.py` (часть приёма) и маршрут. +- [ ] **Step 4: Run** `uv run pytest -q` — всё зелёное +- [ ] **Step 5: Commit** — `Приём журнала изменений: POST /agent/history (#70)` + +--- + +### Task 3: Отметка журнала — `history` в выдаче `refresh`, `sync_run.history_at` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/history.py` (`SINCE_MARGIN`, `backfill_jql`, `delivery_param`, `accepted`) +- Modify: `src/jira_hub/queue.py` (`deliver_params`, `report_result`) +- Test: `tests/test_history.py`, `tests/test_queue.py`, `tests/test_api.py` (`GET /sync-runs` отдаёт `history_at`) + +**Interfaces:** +- Consumes: `projects.base_jql(conn)`, `queue.is_full_refresh` (вызывает `queue`, не `history`). +- Produces (`history`): + - `backfill_jql(base_jql: str) -> str` — Global Constraints. + - `delivery_param(conn, params: dict) -> dict | None` — значение `history` для выдаваемого `refresh` по спеке `commands` «Журнал изменений в refresh»; полный прогон — `params.get("jql") == projects.base_jql(conn)`; отметка — `SELECT started_at, query FROM sync_run WHERE history_at IS NOT NULL ORDER BY started_at DESC, id DESC LIMIT 1`; `None` — не дописывать. + - `accepted(result) -> bool` — `isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error")`. +- Produces (`queue`): `deliver_params` для `refresh` дописывает `"history"`, если `delivery_param` не `None` (вместе с `boards`); `report_result` при `refresh`, `status == "done"`, `is_full_refresh` и `history.accepted(result)` — `UPDATE sync_run SET history_at = WHERE command_id = ?`. + +- [ ] **Step 1: Write the failing tests** — сценарии спеки `commands` (все шесть) и `data-model` «Отметка журнала…» (журнал принят; ошибка журнала; старый агент; `backfill_error` не держит; `incomplete` не держит; хвост `refresh {keys}` с `history` — `history_at = NULL`) плюс Review Focus 2 и 3: + +```python +def test_first_full_refresh_gets_backfill(conn, add_project): + add_project(conn) + p = history.delivery_param(conn, {"jql": projects.base_jql(conn)}) + assert p == {"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'} + +def test_since_from_last_accepted_run(conn, add_project): # прогон 09:00 с history_at, прогон 10:00 без него + ... + assert history.delivery_param(conn, {"jql": base}) == {"since": "2026-10-22T08:45:00Z"} + +def test_new_project_since_and_backfill(...): # отметка по JQL только с AWPCA, потом STOCKS_DRRK + assert set(p) == {"since", "jql"} and "STOCKS_DRRK" in p["jql"] + +def test_keys_refresh_without_mark_gets_nothing(...): + assert history.delivery_param(conn, {"keys": ["X"]}) is None + +@pytest.mark.parametrize("result, marked", [ + ({"history": {"issues": 3, "changes": 7, "incomplete": []}}, True), + ({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, True), + ({"history": {"error": "HTTP 500"}}, False), ({"history": {"error": ""}}, True), + ({}, False), ({"history": "мусор"}, False), (None, False), +]) +def test_report_sets_history_at(..., result, marked): ... # через queue.claim + report_result полного refresh + +def test_redelivery_recomputes_history(...): # довыдача: history снова в выданных params, в базе params без него +``` + +- [ ] **Step 2: Run** `uv run pytest tests/test_history.py tests/test_queue.py -q` — FAIL +- [ ] **Step 3: Implement** +- [ ] **Step 4: Run** `uv run pytest -q` — всё зелёное +- [ ] **Step 5: Commit** — `Отметка журнала: history в refresh, sync_run.history_at (#70)` + +--- + +### Task 4: Лента `GET /changes` и история в `GET /issues/{key}` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/history.py` (`KINDS`, `DEFAULT_KINDS`, `parse_kinds`, `feed`, `issue_changes`) +- Modify: `src/jira_hub/mirror.py` (`get_issue` добавляет `changes` через `history.issue_changes`; `history` не импортирует `mirror`) +- Modify: `src/jira_hub/api.py` (`h_changes`, маршрут `("GET", r"^/changes$", h_changes, "client")`) +- Test: `tests/test_history.py`, `tests/test_api.py` + +**Interfaces:** +- Produces (`history`): + - `KINDS = ("status", "description", "dates", "comment", "other")`, `DEFAULT_KINDS = ("status", "description", "dates", "comment")`. + - `parse_kinds(value: str | None) -> tuple[str, ...]` — `None`/`""` → `DEFAULT_KINDS`, `"all"` → `KINDS`, иначе через запятую; неизвестный — `ValueError("kinds: …")`. + - `kind_of(field: str) -> str` — вид по логическому полю. + - `feed(conn, since: str, until: str, kinds: tuple, project: str | None = None, key: str | None = None, limit: int = 500) -> dict` — `{since, until, truncated, changes}`; `since`/`until` — UTC в формате `db.now()`; элемент и порядок — спека `hub-api` «Лента изменений». Проект — свои задачи (`EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)` по `issue`), задача вне зеркала в ленту проекта не входит, в ленту без проекта и по `key` — входит (LEFT JOIN). + - `issue_changes(conn, key: str) -> list[dict]` — все строки задачи, элемент ленты без `summary`/`status`, `at DESC`, `NULL` в конце. +- Produces (`api`): `GET /changes` разбирает `since`/`until` через `db.jira_utc` (принимает `Z` и `+03:00`; `None` при мусоре → 422 `bad_request` «since: …»), `since > until` → 422 `since`, `limit` вне 1..2000 → 422 `limit`, неизвестный вид → 422 `kinds`, проект — `_project_param`. + +- [ ] **Step 1: Write the failing tests** — сценарии спеки `hub-api` «Лента изменений» (все пять) и «История задачи в карточке», плюс Review Focus 4 (журнал задачи, которой нет в зеркале: `GET /changes?key=Q` отдаёт событие с `summary = None`), плюс `truncated`: + +```python +def test_feed_truncated(conn): # 3 события, limit=2 → 2 самых новых, truncated = True +def test_feed_dates_kind_value(conn): # duedate: from_value "2026-10-25", from_string "25/окт/26" → from "25/окт/26", from_value "2026-10-25" +def test_changes_route_bad_params(api): # since=вчера → 422 bad_request с "since"; kinds=foo → 422 "kinds"; limit=0 → 422 "limit"; project=NOPE → 404 +``` + +- [ ] **Step 2: Run** `uv run pytest tests/test_history.py tests/test_api.py -q` — FAIL +- [ ] **Step 3: Implement** +- [ ] **Step 4: Run** `uv run pytest -q` — всё зелёное +- [ ] **Step 5: Commit** — `Лента изменений GET /changes и история задачи (#70)` + +--- + +### Task 5: MCP — `changes`, история в `issue`, журнал в `sync_log`; документация + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/jira_hub/mcp_server.py` (инструмент `changes`, `fmt_changes`, `fmt_event`, `fmt_issue`, `sync_log`) +- Modify: `ARCHITECTURE.md` (модуль `history` в таблице и цепочках, поток «Журнал изменений», граница: `issue_change` пишет только приём журнала), `CLAUDE.md` репозитория (схема/данные — при необходимости одной строкой) +- Test: `tests/test_mcp.py` + +**Interfaces:** +- Consumes: `GET /changes`, `GET /issues/{key}` (`changes`), `GET /sync-runs` (`history_at`). +- Produces: + - `changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str` — `@mcp.tool() @guard`, привязка — своя проверка: без `key` ведёт себя как `@scoped(True)`, с `key` — без проекта (вызвать `_unbound_or_unknown(required=not key)`). + - `fmt_event(e: dict, today: date) -> str` и `fmt_changes(data: dict, now: datetime) -> str` — чистые функции, время — местное (`datetime.fromisoformat(at).astimezone()`), тексты — спека `mcp` «Лента изменений в MCP». + - Подписи полей дат: `duedate` — «срок», `planned_start` — «плановое начало», `planned_end` — «плановое окончание». + +- [ ] **Step 1: Write the failing tests** — сценарии спеки `mcp` (пять) и Review Focus 5: + +```python +def test_fmt_event_status(): + e = {"at": "2026-10-22T08:15:00+00:00", "kind": "status", "field": "status", "author": "Петров Пётр", + "from": "В работе", "to": "Готово"} + assert mcp_server.fmt_event(e, today=<локальная дата события>).endswith("Петров Пётр: статус «В работе» → «Готово»") + +def test_fmt_event_dates_by_value(): # from_value "2026-10-25", to_value "2026-10-30" → "срок 25.10 → 30.10"; to_value None → "→ —" +def test_fmt_event_other_day(): # событие не сегодня — "ДД.ММ ЧЧ:ММ …" +def test_changes_hours_and_since(live): # → "либо hours, либо since" +def test_changes_bad_hours(live): # hours=0 → текст причины, без запроса +def test_changes_unbound(monkeypatch): # PROJECT None, без key → NOT_BOUND; с key → идёт в /changes без project +def test_changes_empty(live): # → строка периода + "за период изменений нет" +def test_issue_shows_changes(live): # 7 строк журнала (через POST /agent/history агентским клиентом) → "изменения (7):", 5 строк, "ещё 2" +def test_sync_log_history(live): # "журнал: нет" у прогона без history_at +``` + +- [ ] **Step 2: Run** `uv run pytest tests/test_mcp.py -q` — FAIL +- [ ] **Step 3: Implement** инструмент, форматирование, документацию. +- [ ] **Step 4: Run** `make check` — всё зелёное (pytest + lint и тесты фронта) +- [ ] **Step 5: Commit** — `MCP: лента изменений, история в issue, журнал в sync_log (#70)` -- 2.47.2 From dc9419fbb9389c499901fcd9096277910780c4d6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:05:53 +0400 Subject: [PATCH 3/9] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20v5:=20issu?= =?UTF-8?q?e=5Fchange,=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20?= =?UTF-8?q?=D0=B2=20UTC=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/db.py | 57 ++++++++++++++++++++++++++++++++++-- src/jira_hub/mirror.py | 8 ++--- tests/test_db.py | 66 +++++++++++++++++++++++++++++++++++++----- tests/test_mirror.py | 8 +++++ 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 246604f..b3546eb 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -50,8 +50,28 @@ CREATE TABLE IF NOT EXISTS issue_comment ( created TEXT, updated TEXT, body TEXT NOT NULL DEFAULT '', + created_at TEXT, PRIMARY KEY (key, comment_id) ); +CREATE TABLE IF NOT EXISTS issue_change ( + history_id TEXT NOT NULL, + seq INTEGER NOT NULL, + key TEXT NOT NULL, + at TEXT, + created TEXT NOT NULL, + author TEXT, + field TEXT NOT NULL, + field_id TEXT, + field_name TEXT NOT NULL, + from_value TEXT, + from_string TEXT, + to_value TEXT, + to_string TEXT, + fetched_at TEXT NOT NULL, + PRIMARY KEY (history_id, seq) +); +CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at); +CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at); CREATE TABLE IF NOT EXISTS issue_note ( key TEXT PRIMARY KEY, fact_status TEXT, @@ -99,7 +119,8 @@ CREATE TABLE IF NOT EXISTS sync_run ( links_count INTEGER NOT NULL DEFAULT 0, jira_calls INTEGER NOT NULL DEFAULT 0, rate_limited INTEGER NOT NULL DEFAULT 0, - command_id INTEGER + command_id INTEGER, + history_at TEXT ); CREATE TABLE IF NOT EXISTS project ( label TEXT PRIMARY KEY, @@ -123,12 +144,28 @@ def _migrate_projects(conn) -> None: projects.migrate_v3(conn) +def _add_column(table: str, column: str): + """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой.""" + def step(conn) -> None: + if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT") + return step + + +def _fill_comment_utc(conn) -> None: + for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall(): + conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?", + (jira_utc(r["created"]), r["key"], r["comment_id"])) + + # Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) MIGRATIONS = { 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно 3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки + 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений + _fill_comment_utc], } @@ -217,6 +254,22 @@ def transaction(conn: sqlite3.Connection): conn.execute("COMMIT") +def jira_utc(value) -> str | None: + """Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате + now(); без смещения — UTC; неразбираемое — None.""" + if not isinstance(value, str) or not value: + return None + s = value[:-1] + "+00:00" if value.endswith("Z") else value + if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM + s = s[:-2] + ":" + s[-2:] + try: + d = datetime.fromisoformat(s) + except ValueError: + return None + d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) + return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + def utcnow() -> datetime: return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index ae7503f..eea22c8 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -58,10 +58,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) conn.executemany( - "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) " - "VALUES (?, ?, ?, ?, ?, ?)", - [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "") - for c in it["comments"]]) + "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", + db.jira_utc(c.get("created"))) for c in it["comments"]]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", diff --git a/tests/test_db.py b/tests/test_db.py index 27e8753..a3ae7ea 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -5,6 +5,8 @@ import sqlite3 import stat import threading +import pytest + from jira_hub import backup, db, paths, projects, queue, settings @@ -12,14 +14,23 @@ def test_connect_creates_schema(open_db): conn = open_db() names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run", - "agent_status", "project"} <= names + "agent_status", "project", "issue_change"} <= names + assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} + assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols +def _drop_v5(conn): + """Снять с базы текущей схемы то, что добавила v5: так строятся базы прежних версий.""" + conn.execute("DROP TABLE issue_change") + conn.execute("ALTER TABLE issue_comment DROP COLUMN created_at") + conn.execute("ALTER TABLE sync_run DROP COLUMN history_at") + + def _v1_database(path): """База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными.""" conn = db.connect(path) @@ -28,6 +39,7 @@ def _v1_database(path): conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)", (status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None)) conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _drop_v5(conn) conn.execute("ALTER TABLE command DROP COLUMN deliveries") conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") @@ -39,7 +51,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -186,7 +198,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" conn.close() @@ -196,13 +208,14 @@ def test_migrates_v2_with_backup(tmp_path): conn = db.connect(path) conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')") conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _drop_v5(conn) conn.execute("DROP TABLE issue_comment") conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -247,6 +260,7 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id= (ts, ts, base_jql, cid)) conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,)) conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") + _drop_v5(conn) conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'") @@ -269,7 +283,7 @@ def test_migrates_v3_creates_awpca(tmp_path): queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] assert sorted(queued) == sorted([new, "key = A-1"]) assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -288,5 +302,43 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): conn = db.connect(tmp_path / "hub.sqlite") assert projects.list_projects(conn) == [] assert "ORDER BY key" in caplog.text - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + conn.close() + + +@pytest.mark.parametrize("raw, want", [ + ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11Z", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11.123Z", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"), + ("вчера", None), ("", None), (None, None), (42, None), +]) +def test_jira_utc(raw, want): + assert db.jira_utc(raw) == want + + +def _v4_database(path): + """База v4: комментарий со временем Jira, полный прогон в журнале синхронизаций.""" + conn = db.connect(path) + conn.execute("INSERT INTO issue_comment(key, comment_id, created, body) VALUES ('X', '1', '2026-10-22T13:05:11.000+0300', 'a')") + conn.execute("INSERT INTO issue_comment(key, comment_id, created, body) VALUES ('X', '2', 'мусор', 'b')") + conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query) VALUES " + "('2026-10-22T09:00:00+00:00', '2026-10-22T09:01:00+00:00', 'jql', 'q')") + _drop_v5(conn) + conn.execute("UPDATE setting SET value = '4' WHERE key = 'schema_version'") + conn.close() + + +def test_migrates_v4(tmp_path): + path = tmp_path / "hub.sqlite" + _v4_database(path) + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")} + assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)} + assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None + assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 0 + assert len(list((tmp_path / "backups").glob("*-schema-v4.sqlite"))) == 1 conn.close() diff --git a/tests/test_mirror.py b/tests/test_mirror.py index b308c81..aa093dd 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -311,3 +311,11 @@ def test_labels_of(conn): _scene(conn) assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] assert mirror.labels_of(conn, "AURORA-404") is None + + +def test_apply_state_comment_created_at(conn): + mirror.apply_state(conn, [{"key": "X", "comments": [ + {"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"}, + {"id": "2", "body": "b", "created": "мусор"}]}], []) + rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")} + assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None} -- 2.47.2 From f0590ac9eab9011139f7dbc75dc96c48cd9ca269 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:08:29 +0400 Subject: [PATCH 4/9] =?UTF-8?q?=D0=9F=D1=80=D0=B8=D1=91=D0=BC=20=D0=B6?= =?UTF-8?q?=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9:=20POST=20/agent/history=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 12 +++++++- src/jira_hub/history.py | 59 +++++++++++++++++++++++++++++++++++++ tests/test_api.py | 22 ++++++++++++++ tests/test_history.py | 65 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/jira_hub/history.py create mode 100644 tests/test_history.py diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 95362d0..fad7c4d 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import NamedTuple from urllib.parse import parse_qs, unquote, urlparse -from . import backup, db, files, mirror, projects, queue, relay, release, settings +from . import backup, db, files, history, mirror, projects, queue, relay, release, settings from .commands import CommandError from .paths import backups_dir as default_backups_dir from .paths import secure_dir @@ -210,6 +210,15 @@ def h_agent_state(hub, conn, m, q, body): return 200, out +def h_agent_history(hub, conn, m, q, body): + changes = body.get("changes") + try: + history.check_changes(changes) # до записи: неверная пачка не применяется целиком + except ValueError as e: + raise ApiError(422, "bad_history", str(e)) + return 200, history.record_changes(conn, changes) + + def h_known_keys(hub, conn, m, q, body): return 200, {"keys": mirror.known_keys(conn)} @@ -449,6 +458,7 @@ ROUTES = [ ("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"), ("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"), ("POST", r"^/agent/state$", h_agent_state, "agent"), + ("POST", r"^/agent/history$", h_agent_history, "agent"), ("GET", r"^/agent/known-keys$", h_known_keys, "agent"), ("GET", r"^/agent/release$", h_agent_release, "agent"), ("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"), diff --git a/src/jira_hub/history.py b/src/jira_hub/history.py new file mode 100644 index 0000000..e66a1a2 --- /dev/null +++ b/src/jira_hub/history.py @@ -0,0 +1,59 @@ +"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля. +Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" +from . import db, settings + +BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution") +_REQUIRED = ("history_id", "key", "created", "field_name") +_OPTIONAL = ("author", "field_id", "from", "from_string", "to", "to_string") + + +def check_changes(changes) -> None: + """Форма пачки журнала до записи: нарушение — ValueError с номером элемента и полем (422 bad_history).""" + if not isinstance(changes, list): + raise ValueError("changes — список элементов журнала") + for n, c in enumerate(changes): + where = f"changes[{n}]" + if not isinstance(c, dict): + raise ValueError(f"{where} — объект элемента журнала") + for f in _REQUIRED: + if not isinstance(c.get(f), str) or not c[f]: + raise ValueError(f"{where}.{f} — непустая строка") + seq = c.get("seq") + if not isinstance(seq, int) or isinstance(seq, bool) or seq < 0: + raise ValueError(f"{where}.seq — целое >= 0") + for f in _OPTIONAL: + if c.get(f) is not None and not isinstance(c[f], str): + raise ValueError(f"{where}.{f} — строка или null") + + +def logical_field(item: dict, field_ids: dict | None, epic_link: dict | None) -> str: + """Логическое имя поля журнала: встроенные — по field_id, без него — по field_name; плановые даты и эпик — по id + из настроек; иначе — имя поля Jira как есть.""" + fid, name = item.get("field_id"), item.get("field_name") + if fid: + if fid in BUILTIN_FIELDS: + return fid + for logical in ("planned_start", "planned_end"): + if (field_ids or {}).get(logical) == fid: + return logical + if (epic_link or {}).get("field_id") == fid: + return "epic_link" + elif name in BUILTIN_FIELDS: + return name + return name + + +def record_changes(conn, changes: list[dict]) -> dict: + """Записать проверенную пачку (INSERT OR IGNORE по (history_id, seq)) в одной транзакции.""" + field_ids, epic_link = settings.get(conn, "field_ids"), settings.get(conn, "epic_link") + now, accepted = db.now(), 0 + with db.transaction(conn): + for c in changes: + cur = conn.execute( + "INSERT OR IGNORE INTO issue_change(history_id, seq, key, at, created, author, field, field_id, field_name, " + "from_value, from_string, to_value, to_string, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (c["history_id"], c["seq"], c["key"], db.jira_utc(c["created"]), c["created"], c.get("author"), + logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"), + c.get("from_string"), c.get("to"), c.get("to_string"), now)) + accepted += cur.rowcount + return {"accepted": accepted, "duplicates": len(changes) - accepted} diff --git a/tests/test_api.py b/tests/test_api.py index 07ee54b..89f8d76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -941,3 +941,25 @@ def test_board_type_round_trip(hub): # сце with pytest.raises(HubError) as e: c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]}) assert e.value.status == 422 and "канбан-доска" in e.value.detail + + +HISTORY_ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров", + "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", + "to_string": "Готово"} + + +def test_agent_history_route(hub): + assert hub["agent"].post("/agent/history", {"command_id": None, "changes": [HISTORY_ITEM]}) == {"accepted": 1, "duplicates": 0} + items = [HISTORY_ITEM, {**HISTORY_ITEM, "seq": 1}, {**HISTORY_ITEM, "seq": 2}] + assert hub["agent"].post("/agent/history", {"changes": items}) == {"accepted": 2, "duplicates": 1} + with pytest.raises(HubError) as e: + hub["client"].post("/agent/history", {"changes": items}) + assert e.value.status == 401 + with pytest.raises(HubError) as e: + hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "seq": 7}, {**HISTORY_ITEM, "seq": "0"}]}) + assert e.value.status == 422 and e.value.error == "bad_history" and "changes[1].seq" in e.value.detail + conn = hub["hub"].conn() + try: + assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3 + finally: + conn.close() diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..955c6b3 --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,65 @@ +import pytest + +from jira_hub import history + +ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", + "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", + "to_string": "Готово"} + + +@pytest.fixture +def conn(open_db): + return open_db() + + +def test_record_dedup(conn): + assert history.record_changes(conn, [ITEM]) == {"accepted": 1, "duplicates": 0} + assert history.record_changes(conn, [ITEM, {**ITEM, "seq": 1, "field_id": None, "field_name": "resolution"}]) == \ + {"accepted": 1, "duplicates": 1} + row = conn.execute("SELECT * FROM issue_change WHERE seq = 0").fetchone() + assert row["at"] == "2026-10-22T06:00:00+00:00" and row["created"] == ITEM["created"] + assert row["field"] == "status" and row["from_value"] == "3" and row["to_string"] == "Готово" + assert conn.execute("SELECT field FROM issue_change WHERE seq = 1").fetchone()["field"] == "resolution" + + +def test_record_keeps_first_version(conn): + history.record_changes(conn, [ITEM]) + history.record_changes(conn, [{**ITEM, "to_string": "Снято"}]) + assert conn.execute("SELECT to_string FROM issue_change").fetchone()["to_string"] == "Готово" + + +def test_record_uses_field_settings(conn): + from jira_hub import settings + settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"}) + history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}]) + assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end" + + +@pytest.mark.parametrize("item, field_ids, epic, want", [ + ({"field_id": "customfield_10201", "field_name": "Плановое окончание"}, {"planned_end": "customfield_10201"}, {}, "planned_end"), + ({"field_id": "customfield_10200", "field_name": "Плановое начало"}, {"planned_start": "customfield_10200"}, {}, "planned_start"), + ({"field_id": None, "field_name": "duedate"}, {}, {}, "duedate"), + ({"field_id": "status", "field_name": "Статус"}, {}, {}, "status"), + ({"field_id": None, "field_name": "Fix Version"}, {}, {}, "Fix Version"), + ({"field_id": "customfield_1", "field_name": "Epic Link"}, {}, {"field_id": "customfield_1"}, "epic_link"), + ({"field_id": "customfield_9", "field_name": "Sprint"}, None, None, "Sprint"), +]) +def test_logical_field(item, field_ids, epic, want): + assert history.logical_field(item, field_ids, epic) == want + + +@pytest.mark.parametrize("bad, field", [({"seq": "0"}, "seq"), ({"seq": -1}, "seq"), ({"seq": True}, "seq"), + ({"key": ""}, "key"), ({"history_id": 5001}, "history_id"), + ({"created": None}, "created"), ({"field_name": ""}, "field_name"), + ({"to_string": 5}, "to_string"), ({"author": []}, "author")]) +def test_check_changes_rejects(bad, field): + with pytest.raises(ValueError, match=rf"changes\[1\]\.{field}"): + history.check_changes([ITEM, {**ITEM, **bad}]) + + +def test_check_changes_shape(): + with pytest.raises(ValueError, match="changes — список"): + history.check_changes({"a": 1}) + with pytest.raises(ValueError, match=r"changes\[0\]"): + history.check_changes(["x"]) + history.check_changes([ITEM, {**ITEM, "author": None, "field_id": None, "from": None, "to_string": None}]) -- 2.47.2 From e3649a1a38f30b24524b8091dfa1a15a25eb8a5b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:12:16 +0400 Subject: [PATCH 5/9] =?UTF-8?q?=D0=9E=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0:=20history=20?= =?UTF-8?q?=D0=B2=20refresh,=20sync=5Frun.history=5Fat=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/history.py | 43 +++++++++++++++++--- src/jira_hub/queue.py | 14 +++++-- tests/test_api.py | 10 +++++ tests/test_queue.py | 87 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/src/jira_hub/history.py b/src/jira_hub/history.py index e66a1a2..8dab465 100644 --- a/src/jira_hub/history.py +++ b/src/jira_hub/history.py @@ -1,6 +1,10 @@ -"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля. -Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" -from . import db, settings +"""Журнал изменений задач (issue_change): приём от агента, логическое имя поля, отметка «с какого момента» для +refresh. Пишет таблицу только приём журнала (POST /agent/history); API и MCP её только читают (spec data-model).""" +from datetime import timedelta + +from . import db, projects, settings + +SINCE_MARGIN = timedelta(minutes=15) # расхождение часов Mac mini и Jira и длительность прогона (design #5) BUILTIN_FIELDS = ("status", "description", "summary", "assignee", "priority", "duedate", "labels", "resolution") _REQUIRED = ("history_id", "key", "created", "field_name") @@ -46,7 +50,7 @@ def logical_field(item: dict, field_ids: dict | None, epic_link: dict | None) -> def record_changes(conn, changes: list[dict]) -> dict: """Записать проверенную пачку (INSERT OR IGNORE по (history_id, seq)) в одной транзакции.""" field_ids, epic_link = settings.get(conn, "field_ids"), settings.get(conn, "epic_link") - now, accepted = db.now(), 0 + now, new = db.now(), 0 with db.transaction(conn): for c in changes: cur = conn.execute( @@ -55,5 +59,32 @@ def record_changes(conn, changes: list[dict]) -> dict: (c["history_id"], c["seq"], c["key"], db.jira_utc(c["created"]), c["created"], c.get("author"), logical_field(c, field_ids, epic_link), c.get("field_id"), c["field_name"], c.get("from"), c.get("from_string"), c.get("to"), c.get("to_string"), now)) - accepted += cur.rowcount - return {"accepted": accepted, "duplicates": len(changes) - accepted} + new += cur.rowcount + return {"accepted": new, "duplicates": len(changes) - new} + + +def backfill_jql(base_jql: str) -> str: + """JQL стартовой загрузки: незакрытые задачи открытых спринтов (design #6).""" + return f"({base_jql}) AND sprint in openSprints() AND statusCategory != Done" + + +def delivery_param(conn, params: dict) -> dict | None: + """Параметр history выдаваемого refresh (spec commands «Журнал изменений в refresh»); None — не дописывать.""" + base = projects.base_jql(conn) + if base is None: + return None + mark = conn.execute("SELECT started_at, query FROM sync_run WHERE history_at IS NOT NULL " + "ORDER BY started_at DESC, id DESC LIMIT 1").fetchone() + out = {} + if mark is not None: + since = db.parse(mark["started_at"]) - SINCE_MARGIN + out["since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ") + if params.get("jql") == base and (mark is None or mark["query"] != base): + out["jql"] = backfill_jql(base) + return out or None + + +def accepted(result) -> bool: + """Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete + отметку не держат).""" + return isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error") diff --git a/src/jira_hub/queue.py b/src/jira_hub/queue.py index a407b70..70a62b8 100644 --- a/src/jira_hub/queue.py +++ b/src/jira_hub/queue.py @@ -7,7 +7,7 @@ from datetime import timedelta from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim) from . import commands as C -from . import db, files, mirror, projects, settings +from . import db, files, history, mirror, projects, settings log = logging.getLogger(__name__) @@ -264,10 +264,14 @@ def _redeliver(conn, now) -> dict | None: def deliver_params(conn, cmd, files_dir) -> dict: """Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None, - если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них: - повтор команды проходит проверку как новая.""" + если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала + (change-history). В базе params остаются без них: повтор команды проходит проверку как новая.""" if cmd["type"] == "refresh": - return {**cmd["params"], "boards": projects.boards(conn)} + out = {**cmd["params"], "boards": projects.boards(conn)} + hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5) + if hist is not None: + out["history"] = hist + return out if cmd["type"] != "ensure_attachment": return cmd["params"] size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None @@ -370,6 +374,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls= (now, int(jira_calls or 0), int(rate_limited or 0), command_id)) if isinstance(result, dict) and "boards" in result: projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем + if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result): + conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id)) if status == "done" and is_full_refresh(conn, command_id): # полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) — # почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12) diff --git a/tests/test_api.py b/tests/test_api.py index 89f8d76..66d0676 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -963,3 +963,13 @@ def test_agent_history_route(hub): assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3 finally: conn.close() + + +def test_sync_runs_history_at(hub): + conn = hub["hub"].conn() + try: + conn.execute("INSERT INTO sync_run(started_at, kind, history_at) VALUES ('2026-10-22T09:00:00+00:00', 'jql', " + "'2026-10-22T09:02:00+00:00')") + finally: + conn.close() + assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00" diff --git a/tests/test_queue.py b/tests/test_queue.py index 9fde9fe..1fd3ebd 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -941,3 +941,90 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3 c = queue.claim(conn) queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e") assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban" + + +# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ---------------------------------- + +def _full_run(conn, result, started_at=None, status="done"): + """Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона.""" + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + c = queue.claim(conn) + if started_at: + conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"])) + queue.report_result(conn, c["id"], status, result=result) + return c + + +def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки» + add_project(conn) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + got = queue.claim(conn) + assert got["params"]["history"] == { + "jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'} + assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + + +def test_regular_refresh_gets_since(conn, add_project): # «Регулярный refresh» и «Журнал не дочитан» + add_project(conn) + _full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00") + _full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00") + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"} + + +def test_new_project_gets_since_and_backfill(conn, add_project): # «Новый проект» + add_project(conn) + _full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00") + add_project(conn, "STOCKS_DRRK", board_id=5120) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + h = queue.claim(conn)["params"]["history"] + assert h["since"] == "2026-10-22T08:45:00Z" + assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done" + assert "STOCKS_DRRK" in h["jql"] + + +def test_tail_refresh_since_and_no_mark(conn, add_project): # «Хвост пакета» + add_project(conn) + _full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00") + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + c = queue.claim(conn) + assert c["params"]["history"] == {"since": "2026-10-22T08:45:00Z"} + queue.report_result(conn, c["id"], "done", result={"history": {"issues": 0, "changes": 0, "incomplete": []}}) + assert conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()["history_at"] is None + + +def test_tail_refresh_without_mark_gets_nothing(conn, add_project): # «Хвост пакета до первой отметки» + add_project(conn) + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert "history" not in queue.claim(conn)["params"] + + +def test_no_projects_no_history(conn): + queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) + assert "history" not in queue.claim(conn)["params"] + + +def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3 + add_project(conn) + b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}]) + first = queue.claim(conn) + again = queue.claim(conn) # отчёта нет — довыдача + assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"] + assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] + + +@pytest.mark.parametrize("result, status, marked", [ + ({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "done", True), + ({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, "done", True), + ({"history": {"issues": 0, "changes": 0, "error": ""}}, "done", True), + ({"history": {"error": "HTTP 500"}}, "done", False), + ({"issues": 3}, "done", False), # агент v12 + ({"history": "мусор"}, "done", False), + (None, "done", False), + ({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "failed", False), +]) +def test_report_sets_history_at(conn, add_project, result, status, marked): + add_project(conn) + c = _full_run(conn, result, status=status) + run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone() + assert (run["history_at"] is not None) == marked -- 2.47.2 From 337e36f1a0b46d8416369528f9144a9aa448ad94 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:15:23 +0400 Subject: [PATCH 6/9] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B8?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20GET=20/chang?= =?UTF-8?q?es=20=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/api.py | 31 +++++++++++ src/jira_hub/history.py | 79 +++++++++++++++++++++++++++++ src/jira_hub/mirror.py | 3 +- tests/test_api.py | 31 +++++++++++ tests/test_history.py | 110 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 1 deletion(-) diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index fad7c4d..722cc02 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -9,6 +9,7 @@ import re import threading import time import traceback +from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import NamedTuple @@ -250,6 +251,35 @@ def h_issue(hub, conn, m, q, body): return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") +def _time_param(q, name, default): + raw = q.get(name) + if raw in (None, ""): + return default + value = db.jira_utc(raw) + if value is None: + raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") + return value + + +def h_changes(hub, conn, m, q, body): + project = _project_param(conn, q) + until = _time_param(q, "until", db.now()) + since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat()) + if since > until: + raise ApiError(422, "bad_request", "since: позже until") + try: + kinds = history.parse_kinds(q.get("kinds")) + except ValueError as e: + raise ApiError(422, "bad_request", str(e)) + try: + limit = int(q.get("limit", 500)) + except (TypeError, ValueError): + limit = 0 + if not 1 <= limit <= 2000: + raise ApiError(422, "bad_request", "limit: целое от 1 до 2000") + return 200, history.feed(conn, since, until, kinds, project=project, key=q.get("key") or None, limit=limit) + + def h_board(hub, conn, m, q, body): return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q)) @@ -465,6 +495,7 @@ ROUTES = [ ("GET", r"^/issues$", h_issues, "client"), ("GET", r"^/issues/([^/]+)$", h_issue, "client"), ("GET", r"^/board$", h_board, "client"), + ("GET", r"^/changes$", h_changes, "client"), ("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"), ("POST", r"^/batches$", h_batches_post, "client"), ("GET", r"^/batches$", h_batches_get, "client"), diff --git a/src/jira_hub/history.py b/src/jira_hub/history.py index 8dab465..0637b57 100644 --- a/src/jira_hub/history.py +++ b/src/jira_hub/history.py @@ -88,3 +88,82 @@ def accepted(result) -> bool: """Итог журнала в результате refresh принят: history — словарь без непустого error (backfill_error и incomplete отметку не держат).""" return isinstance(result, dict) and isinstance(result.get("history"), dict) and not result["history"].get("error") + + +# --- лента и история задачи (spec hub-api «Лента изменений», «История задачи в карточке») ------------------------- + +KINDS = ("status", "description", "dates", "comment", "other") +DEFAULT_KINDS = ("status", "description", "dates", "comment") +DATE_FIELDS = ("duedate", "planned_start", "planned_end") + + +def parse_kinds(value: str | None) -> tuple[str, ...]: + if not value: + return DEFAULT_KINDS + if value.strip() == "all": + return KINDS + kinds = tuple(k.strip() for k in value.split(",") if k.strip()) + unknown = [k for k in kinds if k not in KINDS] + if unknown or not kinds: + raise ValueError(f"kinds: неизвестный вид {', '.join(unknown) or value!r}; допустимо {', '.join(KINDS)} или all") + return kinds + + +def kind_of(field: str) -> str: + if field in ("status", "description"): + return field + return "dates" if field in DATE_FIELDS else "other" + + +def _event(row) -> dict: + """Элемент ленты из строки issue_change: from/to — строка журнала, без неё — значение.""" + fs, ts = row["from_string"], row["to_string"] + return {"at": row["at"], "key": row["key"], "kind": kind_of(row["field"]), "field": row["field"], + "author": row["author"], "from": fs if fs is not None else row["from_value"], + "to": ts if ts is not None else row["to_value"], "from_value": row["from_value"], "to_value": row["to_value"]} + + +_OWN = "EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)" + + +def feed(conn, since: str, until: str, kinds: tuple, project: str | None = None, key: str | None = None, + limit: int = 500) -> dict: + """События за [since, until] (UTC в формате db.now()): журнал и новые комментарии. project — только свои задачи + проекта; key — одна задача без ограничения проектом. Больше limit — первые limit по порядку и truncated.""" + where, args = [], [] + if key: + where.append("x.key = ?") + args.append(key) + elif project: + where.append(_OWN) + args.append(project) + extra = "".join(" AND " + w for w in where) + events = [] # (сортировочный ключ, элемент) + if any(k != "comment" for k in kinds): + for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_change x " + "LEFT JOIN issue i ON i.key = x.key WHERE x.at >= ? AND x.at <= ?" + extra, + [since, until] + args): + e = _event(r) + if e["kind"] in kinds: + events.append(((r["at"], r["key"], r["history_id"], r["seq"]), {**e, "summary": r["i_summary"], + "status": r["i_status"]})) + if "comment" in kinds: + for r in conn.execute("SELECT x.*, i.summary AS i_summary, i.status AS i_status FROM issue_comment x " + "LEFT JOIN issue i ON i.key = x.key WHERE x.created_at >= ? AND x.created_at <= ?" + extra, + [since, until] + args): + events.append(((r["created_at"], r["key"], r["comment_id"], 0), { + "at": r["created_at"], "key": r["key"], "summary": r["i_summary"], "status": r["i_status"], + "kind": "comment", "field": "comment", "author": r["author"], "from": None, "to": r["body"], + "from_value": None, "to_value": None})) + # at — по убыванию, при равном времени — key, history_id, seq по возрастанию + events.sort(key=lambda p: p[0][1:]) + events.sort(key=lambda p: p[0][0], reverse=True) + order = ("at", "key", "summary", "status", "kind", "field", "author", "from", "to", "from_value", "to_value") + return {"since": since, "until": until, "truncated": len(events) > limit, + "changes": [{k: e[k] for k in order} for _, e in events[:limit]]} + + +def issue_changes(conn, key: str) -> list[dict]: + """Весь журнал задачи, новые сверху; строки без времени (at = NULL) — в конце.""" + rows = conn.execute("SELECT * FROM issue_change WHERE key = ? ORDER BY at IS NULL, at DESC, history_id, seq", (key,)) + return [_event(r) for r in rows] diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index eea22c8..0fb3184 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -2,7 +2,7 @@ Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json -from . import db +from . import db, history ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] @@ -154,6 +154,7 @@ def get_issue(conn, key) -> dict | None: d["comments"] = [dict(r) for r in conn.execute( "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] + d["changes"] = history.issue_changes(conn, key) d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", diff --git a/tests/test_api.py b/tests/test_api.py index 66d0676..dd7564f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -973,3 +973,34 @@ def test_sync_runs_history_at(hub): finally: conn.close() assert hub["client"].get("/sync-runs")["runs"][0]["history_at"] == "2026-10-22T09:02:00+00:00" + + +def test_changes_route(hub, add_project): + conn = hub["hub"].conn() + try: + add_project(conn) + finally: + conn.close() + hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "created": "2026-10-22T11:00:00Z"}]}) + got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T13:00:00+03:00", kinds="status") + assert got["since"] == "2026-10-22T10:00:00+00:00" and got["until"] == "2026-10-22T10:00:00+00:00" + got = hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z") + assert [e["key"] for e in got["changes"]] == ["X"] and got["truncated"] is False + assert hub["client"].get("/changes", since="2026-10-22T10:00:00Z", until="2026-10-22T12:00:00Z", + project="AWPCA")["changes"] == [] # X без метки проекта + default = hub["client"].get("/changes") + assert db.parse(default["until"]) - db.parse(default["since"]) == timedelta(hours=24) + + +@pytest.mark.parametrize("params, status, name", [ + ({"since": "вчера"}, 422, "since"), ({"until": "x"}, 422, "until"), + ({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"), + ({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"), + ({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None), +]) +def test_changes_route_bad_params(hub, params, status, name): + with pytest.raises(HubError) as e: + hub["client"].get("/changes", **params) + assert e.value.status == status + if name: + assert e.value.error == "bad_request" and name in e.value.detail diff --git a/tests/test_history.py b/tests/test_history.py index 955c6b3..770dd32 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -63,3 +63,113 @@ def test_check_changes_shape(): with pytest.raises(ValueError, match=r"changes\[0\]"): history.check_changes(["x"]) history.check_changes([ITEM, {**ITEM, "author": None, "field_id": None, "from": None, "to_string": None}]) + + +# --- лента и история задачи (Task 4) ---------------------------------------------------------------------------- + +def _issue(conn, key, labels, summary="Тема", status="В работе"): + import json + conn.execute("INSERT INTO issue(key, summary, status, labels, fetched_at) VALUES (?, ?, ?, ?, '2026-10-22T00:00:00+00:00')", + (key, summary, status, json.dumps(labels))) + + +def _change(conn, key, hid, created, field_name, frm=None, to=None, seq=0, field_id=None, from_value=None, to_value=None): + history.record_changes(conn, [{"history_id": hid, "seq": seq, "key": key, "created": created, "author": "Петров", + "field_id": field_id, "field_name": field_name, "from": from_value, + "from_string": frm, "to": to_value, "to_string": to}]) + + +def test_feed_closed_in_two_hours(conn): # сценарий «Что закрылось за два часа» + _issue(conn, "X", ["AWPCA"], status="Готово") + _issue(conn, "Y", ["STOCKS_DRRK"]) + _change(conn, "X", "1", "2026-10-22T14:15:00.000+0300", "status", "В работе", "Готово") + _change(conn, "Y", "2", "2026-10-22T14:20:00.000+0300", "status", "Открыта", "В работе") + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), project="AWPCA") + assert got["truncated"] is False + assert got["changes"] == [{"at": "2026-10-22T11:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", + "kind": "status", "field": "status", "author": "Петров", "from": "В работе", "to": "Готово", + "from_value": None, "to_value": None}] + + +def test_feed_comment(conn): # «Комментарий в ленте» + from jira_hub import mirror + mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [ + {"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], []) + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS) + assert got["changes"] == [{"at": "2026-10-22T10:30:00+00:00", "key": "X", "summary": None, "status": None, + "kind": "comment", "field": "comment", "author": "Иванов", "from": None, + "to": "Готово к проверке", "from_value": None, "to_value": None}] + + +def test_feed_other_only_on_request(conn): # «Прочие поля по запросу» + _change(conn, "X", "1", "2026-10-22T11:00:00Z", "assignee", "a", "b", field_id="assignee") + window = ("2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00") + assert history.feed(conn, *window, history.DEFAULT_KINDS)["changes"] == [] + got = history.feed(conn, *window, history.parse_kinds("all"))["changes"] + assert [(e["kind"], e["field"]) for e in got] == [("other", "assignee")] + + +def test_feed_neighbor_not_in_project(conn): # «Сосед проекта не в ленте» + _issue(conn, "A", ["AWPCA"]) + _issue(conn, "Z", ["OTHER"]) + conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('Z', 'A', 'блок')") + _change(conn, "Z", "1", "2026-10-22T11:00:00Z", "status", "a", "b") + window = ("2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00") + assert history.feed(conn, *window, ("status",), project="AWPCA")["changes"] == [] + assert len(history.feed(conn, *window, ("status",), key="Z")["changes"]) == 1 + + +def test_feed_issue_not_in_mirror(conn): # Review Focus 4 + _change(conn, "Q", "1", "2026-10-22T11:00:00Z", "status", "a", "b") + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), key="Q")["changes"] + assert got[0]["key"] == "Q" and got[0]["summary"] is None and got[0]["status"] is None + assert history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",))["changes"] + + +def test_feed_truncated_and_order(conn): + for n, t in enumerate(("10:10", "10:30", "10:20")): + _change(conn, "X", str(n), f"2026-10-22T{t}:00Z", "status", "a", "b") + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",), limit=2) + assert got["truncated"] is True + assert [e["at"][11:16] for e in got["changes"]] == ["10:30", "10:20"] + + +def test_feed_window_bounds(conn): + _change(conn, "X", "1", "2026-10-22T10:00:00Z", "status", "a", "b") + _change(conn, "X", "2", "2026-10-22T12:00:01Z", "status", "b", "c") + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("status",))["changes"] + assert [e["to"] for e in got] == ["b"] + + +def test_feed_dates_kind_value(conn): + _change(conn, "X", "1", "2026-10-22T11:00:00Z", "duedate", "25/окт/26", "30/окт/26", field_id="duedate", + from_value="2026-10-25", to_value="2026-10-30") + _change(conn, "X", "2", "2026-10-22T11:05:00Z", "description", None, None, field_id="description", + from_value="старое", to_value="новое") + got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", ("dates", "description"))["changes"] + assert [(e["kind"], e["from"], e["from_value"]) for e in got] == [ + ("description", "старое", "старое"), ("dates", "25/окт/26", "2026-10-25")] + + +@pytest.mark.parametrize("value, want", [(None, history.DEFAULT_KINDS), ("", history.DEFAULT_KINDS), + ("all", history.KINDS), ("status, comment", ("status", "comment"))]) +def test_parse_kinds(value, want): + assert history.parse_kinds(value) == want + + +def test_parse_kinds_unknown(): + with pytest.raises(ValueError, match="kinds"): + history.parse_kinds("status,foo") + + +def test_issue_changes(conn): # «Задача с историей» + from jira_hub import mirror + _issue(conn, "X", ["AWPCA"]) + _change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b") + _change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description") + _change(conn, "X", "3", "мусор", "assignee", "e", "f", field_id="assignee") + got = mirror.get_issue(conn, "X")["changes"] + assert [(e["kind"], e["at"]) for e in got] == [("description", "2026-10-22T11:00:00+00:00"), + ("status", "2026-10-21T11:00:00+00:00"), ("other", None)] + assert "summary" not in got[0] and "status" not in got[0] + assert mirror.get_issue(conn, "X")["changes"] and history.issue_changes(conn, "NOPE") == [] -- 2.47.2 From a27ebc958671e568b9ee37c5346554cd0f813a7d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:23:39 +0400 Subject: [PATCH 7/9] =?UTF-8?q?MCP:=20=D0=BB=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9,=20?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D0=B2=20issue,=20?= =?UTF-8?q?=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B2=20sync=5Flog=20(#?= =?UTF-8?q?70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 16 +++-- CLAUDE.md | 2 +- src/jira_hub/mcp_server.py | 88 +++++++++++++++++++++++++- tests/test_mcp.py | 122 ++++++++++++++++++++++++++++++++++++- 4 files changed, 219 insertions(+), 9 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b82c633..00f1b26 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -7,10 +7,11 @@ | Модуль | Ответственность | Зависит от | |---|---|---| | `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib | -| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | +| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | -| `mirror` | зеркало задач, связей и комментариев, разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` | +| `mirror` | зеркало задач, связей и комментариев (время комментария в UTC — `created_at`), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` | +| `history` | журнал изменений задач `issue_change` (схема v5): проверка и запись пачек агента (`check_changes`, `record_changes`, логическое имя поля `logical_field`), параметр `history` для выдачи `refresh` (`delivery_param`: отметка — последний полный прогон с `sync_run.history_at`, минус 15 минут; стартовая JQL `backfill_jql`), признак принятого журнала `accepted`, лента `feed` (журнал + новые комментарии по `issue_comment.created_at`) и история задачи `issue_changes` | `db`, `settings`, `projects` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | | `backup` | копии файла базы, ротация | `paths` | @@ -19,7 +20,7 @@ | `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` | | `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` | | `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) | -| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) | +| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) | | `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` | | `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` | @@ -38,22 +39,27 @@ боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только источник и события снимка. -Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. +Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`. ## Потоки данных - Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`. - Вложение: файл → `POST /files` → `files/` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки. - Создание проекта: `POST /projects` (веб или `project_create`) → `projects.create` и пакет «Новый проект <метка>» (`refresh` по общей JQL, `batch.project = NULL`) в одной транзакции → `claim` (хаб дописывает в `refresh` доски проектов, `boards`) → агент определяет тип доски → отчёт `refresh` пишет `board_type` → `ensure_sprint` проекта получает `board_id`. - Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`. +- Журнал изменений: выдача `refresh` (`queue.deliver_params` дописывает `history` — `since` и/или стартовую `jql`) → + агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы + отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона + (новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы - Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. +- Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). -- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects` напрямую и ходит только через `client`; из `files` берёт только предел +- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history` напрямую и ходит только через `client`; из `files` берёт только предел `MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`). - `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP). - Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит. diff --git a/CLAUDE.md b/CLAUDE.md index 0352b92..00ebcc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ - Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента. - Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь. - Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed). -- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. +- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790. - Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное. ## Архитектура diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index dd4400d..53b59b0 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -7,6 +7,7 @@ import inspect import json import stat import time +from datetime import datetime, timedelta from pathlib import Path from mcp.server.fastmcp import FastMCP @@ -141,10 +142,66 @@ def fmt_issue(d) -> str: cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else []) else: cmts = ["комментариев нет"] + changes = d.get("changes") or [] + if changes: + cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \ + ([f" ещё {len(changes) - 5}"] if len(changes) > 5 else []) return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"]) +DATE_LABELS = {"duedate": "срок", "planned_start": "плановое начало", "planned_end": "плановое окончание"} + + +def _day(value, text) -> str: + """Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—».""" + if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit(): + return f"{value[8:10]}.{value[5:7]}" + return _v(text) + + +def fmt_event(e: dict, today) -> str: + """Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today + (today = None — дата всегда).""" + if e.get("at"): + local = datetime.fromisoformat(e["at"]).astimezone() + when = local.strftime("%H:%M") if today is not None and local.date() == today else local.strftime("%d.%m %H:%M") + else: + when = "—" + kind, frm, to = e.get("kind"), e.get("from"), e.get("to") + if kind == "comment": + what = "комментарий: " + _cut(to, 200) + elif kind == "status": + what = f"статус «{_v(frm)}» → «{_v(to)}»" + elif kind == "description": + what = "описание изменено" + elif kind == "dates": + what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → " + f"{_day(e.get('to_value'), to)}") + else: + what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»" + return f"{when} {_v(e.get('author'))}: {what}" + + +def fmt_changes(data: dict, now: datetime) -> str: + """Лента по задачам: задачи — по последнему событию (лента уже новые сверху), под задачей — её события.""" + def span(at): + return datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") + lines = [f"изменения с {span(data['since'])} по {span(data['until'])}"] + events = data.get("changes") or [] + if not events: + return "\n".join(lines + ["за период изменений нет"]) + groups: dict[str, list] = {} + for e in events: + groups.setdefault(e["key"], []).append(e) + for key, items in groups.items(): + lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}") + lines += [" " + fmt_event(e, now.date()) for e in items] + if data.get("truncated"): + lines.append(f"показаны последние {len(events)} событий, сузьте период") + return "\n".join(lines) + + def fmt_batch(b) -> str: est = round((b.get("remaining_seconds") or 0) / 60) lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; " @@ -204,6 +261,29 @@ def issue(key: str) -> str: return fmt_issue(_client().get(f"/issues/{key}")) +@mcp.tool() +@guard +def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: + """Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала + сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 со смещением; kinds — через запятую из + status, description, dates, comment, other или all; key — одна задача (без привязки к проекту).""" + if hours is not None and since: + return "либо hours, либо since" + if hours is not None and hours <= 0: + return "hours — число больше 0" + refusal = _unbound_or_unknown(required=not key) # с key — любая задача, как issue + if refusal is not None: + return refusal + now = datetime.now().astimezone() + if hours is not None: + since = (now - timedelta(hours=hours)).isoformat() + elif not since: + since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() + data = _client().get("/changes", since=since, until=until, kinds=kinds, key=key, + project=None if key else PROJECT) + return fmt_changes(data, now) + + @mcp.tool() @guard @scoped(True) @@ -379,9 +459,13 @@ def agent_status() -> str: def sync_log(limit: int = 10) -> str: """Журнал обновлений зеркала.""" runs = _client().get("/sync-runs", limit=limit)["runs"] + + def journal(r): + at = r.get("history_at") + return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет" return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " - f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}" - for r in runs) or "обновлений не было" + f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, " + f"{journal(r)}" for r in runs) or "обновлений не было" @mcp.tool() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index dab86e3..7eb2564 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -159,7 +159,7 @@ def test_sync_log_content(monkeypatch): runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None, "issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}] monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs)) - assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1" + assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет" def test_async_tools_are_coroutines_and_registered(): @@ -492,3 +492,123 @@ def test_run_binds_project(monkeypatch): monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None) mcp_server.run("AWPCA") assert mcp_server.PROJECT == "AWPCA" + + +# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------ + +from datetime import datetime, timedelta, timezone # noqa: E402 + + +def _local(at: str) -> datetime: + return datetime.fromisoformat(at).astimezone() + + +def _ev(**kw): + base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status", + "field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None, + "to_value": None} + return {**base, **kw} + + +def test_fmt_event_status(): + e = _ev() + hm = _local(e["at"]).strftime("%H:%M") + assert mcp_server.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»" + + +def test_fmt_event_other_day_has_date(): + e = _ev() + d = _local(e["at"]) + assert mcp_server.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M ")) + assert mcp_server.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M ")) + + +def test_fmt_event_dates_by_value(): + e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None) + assert mcp_server.fmt_event(e, None).endswith(": срок 25.10 → —") + e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0") + assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10") + + +def test_fmt_event_kinds(): + assert mcp_server.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено") + long = "к" * 250 + out = mcp_server.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None) + assert out.endswith(": комментарий: " + "к" * 199 + "…") + out = mcp_server.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None) + assert out.endswith(": assignee: «—» → «ivanov»") + + +def test_fmt_changes_grouped(): + now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone() + data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [ + _ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"), + _ev(at="2026-10-22T08:15:00+00:00"), + _ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description", + field="description")]} + lines = mcp_server.fmt_changes(data, now).split("\n") + assert lines[0].startswith("изменения с ") + assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено") + assert lines[4] == "X Тема — Готово" + assert lines[-1] == "показаны последние 3 событий, сузьте период" + + +def test_fmt_changes_empty(): + out = mcp_server.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00", + "truncated": False, "changes": []}, datetime.now().astimezone()) + assert out.split("\n")[1] == "за период изменений нет" + + +def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw): + items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров", + "field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None, + "to_string": f"s{i}", **kw} for i in range(n)] + live["agent"].post("/agent/history", {"changes": items}) + + +def test_changes_today(live): # «Что закрылось сегодня» / «За два часа» + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово", + "labels": ["AWPCA"]}], "links": []}) + now = datetime.now(timezone.utc).replace(microsecond=0) + _history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat()) + _history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте + out = mcp_server.changes(kinds="status") + assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out + assert "A-1" in mcp_server.changes(hours=2) + assert "B-1" in mcp_server.changes(key="B-1") + + +def test_changes_hours_and_since(live): + assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since" + assert mcp_server.changes(hours=0) == "hours — число больше 0" + + +def test_changes_bad_since(live): + assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422") + + +def test_changes_empty(live): + assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет" + + +def test_changes_unbound(live, monkeypatch): + monkeypatch.setattr(mcp_server, "PROJECT", None) + assert mcp_server.changes() == mcp_server.NOT_BOUND + assert mcp_server.changes(key="A-1").split("\n")[1] == "за период изменений нет" + + +def test_issue_shows_changes(live): # «История в карточке» + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []}) + assert "изменения (" not in mcp_server.issue("A-1") + _history(live, "A-1", 7) + out = mcp_server.issue("A-1") + assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5 + + +def test_sync_log_history(live): + asyncio.run(mcp_server.refresh(full=True)) + cmd = live["agent"].post("/agent/claim", {})["command"] + assert "журнал: нет" in mcp_server.sync_log() + live["agent"].post(f"/agent/commands/{cmd['id']}/result", + {"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}}) + assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log()) -- 2.47.2 From c6a5ef15b32c7f315acc185a927b465802a036fd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:50:26 +0400 Subject: [PATCH 8/9] =?UTF-8?q?=D0=9B=D0=B5=D0=BD=D1=82=D0=B0:=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D0=B9=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=80=D0=BE=D0=BD=D1=8F=D1=8E=D1=82=20=D1=85?= =?UTF-8?q?=D0=B0=D0=B1,=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D1=81=D0=BC=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=E2=80=94=20422=20=D0=B2=20API=20=D0=B8=20=D0=BC=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=BE=D0=B5=20=D0=B2=20MCP=20(#70,=20M-1,=20M-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../changes/change-history/specs/agent/spec.md | 9 +++++++-- .../changes/change-history/specs/hub-api/spec.md | 2 +- openspec/changes/change-history/specs/mcp/spec.md | 3 ++- src/jira_hub/api.py | 5 ++++- src/jira_hub/db.py | 6 +++--- src/jira_hub/mcp_server.py | 15 +++++++++++++-- tests/test_api.py | 1 + tests/test_db.py | 1 + tests/test_mcp.py | 5 +++++ 9 files changed, 37 insertions(+), 10 deletions(-) diff --git a/openspec/changes/change-history/specs/agent/spec.md b/openspec/changes/change-history/specs/agent/spec.md index 0fbad25..43882cb 100644 --- a/openspec/changes/change-history/specs/agent/spec.md +++ b/openspec/changes/change-history/specs/agent/spec.md @@ -8,8 +8,8 @@ `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей; из журнала каждой задачи взять записи с `created` не раньше `since`. Таких задач нет — вызовов Jira на этом шаге нет. - `jql` (стартовая загрузка) — агент SHALL выполнить `search` по `history.jql` с `fields = key`, `expand = changelog` - (постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Задача, уже прочитанная регулярной частью, - повторно не читается. + (постранично, `maxResults = 100`) и взять журнал найденных задач целиком. Стартовая часть выполняется первой; + регулярная часть не запрашивает журнал задач, уже прочитанных стартовой (их журнал уже взят целиком). Если у задачи `changelog.total` больше числа записей в ответе, агент SHALL дочитать журнал `GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и он неполный — передать что есть, записать в лог ключ @@ -47,6 +47,11 @@ from_string, to, to_string}`: `history_id` — id записи строкой; ` 12 задач - **THEN** `search` по `history.jql` с `expand = changelog`; журнал 12 задач отправлен целиком +#### Scenario: Задача в обеих выборках +- **WHEN** `history = {since, jql}`, у X записи журнала от 2020 года и от сегодня, X изменилась после `since` и входит в + выборку `history.jql` +- **THEN** хабу отправлены обе записи X; журнал X запрошен одним `search` (стартовым) + #### Scenario: Плановая дата без fieldId - **WHEN** элемент журнала `{field: "Плановое окончание", fieldtype: "custom"}` без `fieldId`; в `GET /field` у поля с этим именем id `customfield_10201` diff --git a/openspec/changes/change-history/specs/hub-api/spec.md b/openspec/changes/change-history/specs/hub-api/spec.md index cadacb8..2b8f58c 100644 --- a/openspec/changes/change-history/specs/hub-api/spec.md +++ b/openspec/changes/change-history/specs/hub-api/spec.md @@ -24,7 +24,7 @@ from_string, to, to_string}`. До записи хаб SHALL проверить ### Requirement: Лента изменений Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами: -- `since`, `until` — ISO-8601 со смещением или `Z`; без `until` — текущее время; без `since` — `until` минус 24 часа; +- `since`, `until` — ISO-8601 со смещением или `Z` (время без смещения — 422); без `until` — текущее время; без `since` — `until` минус 24 часа; - `project` — только свои задачи проекта (метка проекта; соседи не входят); неизвестный проект — 404; - `key` — события одной задачи (без ограничения проектом); - `kinds` — через запятую из `status`, `description`, `dates`, `comment`, `other`, или `all`; по умолчанию — diff --git a/openspec/changes/change-history/specs/mcp/spec.md b/openspec/changes/change-history/specs/mcp/spec.md index ec3982d..0aea727 100644 --- a/openspec/changes/change-history/specs/mcp/spec.md +++ b/openspec/changes/change-history/specs/mcp/spec.md @@ -3,7 +3,8 @@ ### Requirement: Лента изменений в MCP Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`, `until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `since` — с начала -текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. В сессии, привязанной к +текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. `since`/`until` без +смещения считаются местным временем машины MCP-сервера (хаб время без смещения отвергает). В сессии, привязанной к проекту P, без `key` — `project = P`; с `key` — без проекта. Без привязки и без `key` — ответ «сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)», в хаб не ходить; с `key` — работает. diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 722cc02..18b102a 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -251,11 +251,14 @@ def h_issue(hub, conn, m, q, body): return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue") +_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$") + + def _time_param(q, name, default): raw = q.get(name) if raw in (None, ""): return default - value = db.jira_utc(raw) + value = db.jira_utc(raw) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2) if value is None: raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z") return value diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index b3546eb..8647a0b 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -264,10 +264,10 @@ def jira_utc(value) -> str | None: s = s[:-2] + ":" + s[-2:] try: d = datetime.fromisoformat(s) - except ValueError: + d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) + return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() + except (ValueError, OverflowError): # время за краем диапазона datetime — тоже «не разобралось» return None - d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) - return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() def utcnow() -> datetime: diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 53b59b0..be5bc4e 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -261,11 +261,22 @@ def issue(key: str) -> str: return fmt_issue(_client().get(f"/issues/{key}")) +def _local_if_naive(value: str) -> str: + """Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть.""" + if not value: + return value + try: + d = datetime.fromisoformat(value) + except ValueError: + return value + return d.astimezone().isoformat() if d.tzinfo is None else value + + @mcp.tool() @guard def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str: """Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала - сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 со смещением; kinds — через запятую из + сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из status, description, dates, comment, other или all; key — одна задача (без привязки к проекту).""" if hours is not None and since: return "либо hours, либо since" @@ -279,7 +290,7 @@ def changes(hours: float | None = None, since: str = "", until: str = "", kinds: since = (now - timedelta(hours=hours)).isoformat() elif not since: since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat() - data = _client().get("/changes", since=since, until=until, kinds=kinds, key=key, + data = _client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key, project=None if key else PROJECT) return fmt_changes(data, now) diff --git a/tests/test_api.py b/tests/test_api.py index dd7564f..3ad6f5e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -997,6 +997,7 @@ def test_changes_route(hub, add_project): ({"since": "2026-10-22T12:00:00Z", "until": "2026-10-22T10:00:00Z"}, 422, "since"), ({"kinds": "foo"}, 422, "kinds"), ({"limit": "0"}, 422, "limit"), ({"limit": "2001"}, 422, "limit"), ({"limit": "x"}, 422, "limit"), ({"project": "NOPE"}, 404, None), + ({"since": "2020-01-01T09:00"}, 422, "since"), ({"until": "2026-10-22"}, 422, "until"), # M-2: без смещения ]) def test_changes_route_bad_params(hub, params, status, name): with pytest.raises(HubError) as e: diff --git a/tests/test_db.py b/tests/test_db.py index a3ae7ea..c4d562f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -314,6 +314,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): ("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"), ("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"), ("вчера", None), ("", None), (None, None), (42, None), + ("9999-12-31T23:00:00-0300", None), ("0001-01-01T00:00:00+0300", None), # M-1: выход за диапазон datetime ]) def test_jira_utc(raw, want): assert db.jira_utc(raw) == want diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 7eb2564..715daf0 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -587,6 +587,11 @@ def test_changes_bad_since(live): assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422") +def test_changes_naive_since_is_local(live): # M-2: время без смещения — местное + out = mcp_server.changes(since="2026-10-22T09:00", until="2026-10-22T10:00") + assert out.split("\n")[0] == "изменения с 22.10 09:00 по 22.10 10:00" + + def test_changes_empty(live): assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет" -- 2.47.2 From 479bb6625fcc4bd60f4ff832eb3f3614e490e68f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:50:41 +0400 Subject: [PATCH 9/9] =?UTF-8?q?=D0=A0=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B2=20design.md=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/change-history/design.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openspec/changes/change-history/design.md b/openspec/changes/change-history/design.md index dbdcd4e..54d6acc 100644 --- a/openspec/changes/change-history/design.md +++ b/openspec/changes/change-history/design.md @@ -216,3 +216,18 @@ значению.** Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее. 16. 2026-10-22 — **`sync_log` показывает, принят ли журнал прогона (`history_at`).** Обоснование: дизайн обещал видимость ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP. +17. 2026-10-22 — **R-1: шаги миграции v4 → v5 добавляют колонку, только если её нет (`_add_column`).** Обоснование: + базы v1/v2 получают `issue_comment` из `SCHEMA` уже с `created_at`, голый `ALTER` падал бы «duplicate column». + Цена ошибки: нет — для базы v4 поведение то же. +18. 2026-10-22 — **R-2: прежний тест формата `sync_log` переписан на строку с «, журнал: нет».** Обоснование: спека + `mcp` добавляет журнал к каждой строке. Цена ошибки: нет. +19. 2026-10-22 — **R-7 (по I-1 финального ревью): агент выполняет стартовую часть журнала первой, регулярная не + запрашивает уже прочитанные задачи; задача помечается прочитанной только после разбора её журнала.** Обоснование: + при `history = {since, jql}` задача из обеих выборок теряла записи старше `since` (их отсекала регулярная часть, а + стартовая её пропускала), хотя полный журнал уже был получен; спека `agent` уточнена. Цена ошибки: нет лишних + вызовов Jira; если стартовая часть упала на задаче, её добирает регулярная (только записи после `since`). +20. 2026-10-22 — **R-8 (по M-1/M-2 финального ревью, подняты до Important по эффекту): время за краем диапазона + `datetime` — «не разобралось» (`None`), а не исключение; `GET /changes` отвечает 422 на время без смещения, MCP + `changes` считает такое время местным.** Обоснование: исключение роняло миграцию (хаб не стартует) или навсегда + стопорило отметку журнала; время без смещения из чата молча сдвигало окно ленты на 3 часа. Цена ошибки: запрос API + без смещения теперь 422 — интерфейсов, которые так шлют, нет. -- 2.47.2