33 KiB
33 KiB
Context
main 7911dc7, схема v4, агент v12. Устройство, на которое опираются правки:
- Зеркало —
mirror.apply_stateделает upsert строкиissue(прежние значения теряются) и заменяет комментарии задачи присланными (issue_comment, времяcreated/updated— строка Jira как есть, например2026-10-22T13:05:11.000+0300). Полеjira_updated(Jiraupdated) уже приходит в состоянии. - Полный прогон —
refresh {jql: base_jql}(queue.is_full_refresh); его ставят автообновление (queue.maybe_auto_refresh, по умолчанию раз в 60 минут,auto_refresh_minutes),POST /refresh {full}и MCPrefresh 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
- Таблица
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 часто меняет статус и резолюцию разом. - Время комментариев в UTC. Колонка
issue_comment.created_at(ISO-8601 UTC, приведённое изcreated; не разобралось —NULL), заполняется вapply_state; миграция v4 → v5 заполняет её у имеющихся строк. Строкаcreatedостаётся как есть (её показывают интерфейсы). Время журнала приводится так же (at). Разбор: ISO-8601 со смещением+0300/+03:00/Z, с дробной частью или без; без смещения — UTC. - Логическое имя поля. Хаб вычисляет
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 полей уже живёт в настройках хаба, агент шлёт сырые данные. - Виды событий ленты.
status— полеstatus;description— полеdescription;dates— поляduedate,planned_start,planned_end;comment— комментарий сcreated_atв окне (изissue_comment, не из журнала);other— прочие поля журнала. По умолчанию лента отдаётstatus,description,dates,comment;other— только по запросу (kinds=allили явно). Остальные поля журнала сохраняются всегда — приходят бесплатно, понадобятся вебу. - Отметка «с какого момента» — из
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(после нового проекта прежние проекты теряли изменения вне спринта между отметкой и прогоном — находка рецензента).
- полный прогон, отметка есть —
- Стартовая загрузка.
history.jql = (<base_jql>) AND sprint in openSprints() AND statusCategory != Done. Агент тянет журнал этих задач целиком (без отсечения по времени). Решение владельца: «максимум незакрытые в текущем спринте».openSprints()— активные спринты, будущие не входят. Категория статуса, а не настройкаclosed_statuses: статус из настройки, которого нет в Jira, даёт 400 на всю JQL, и стартовая загрузка не прошла бы никогда. Стартовая загрузка — разовая и «по возможности»: её ошибка не мешает отметке (решение 11) и не повторяется. Проект на канбан-доске без спринтов в стартовую загрузку не попадает — его история начнётся с первого регулярного прогона. - Агент v13: регулярный режим (
history.since). После отправки состояния (все пачкиPOST /agent/state) агент отбирает из выгруженных задач (база и соседи) те, у кого Jiraupdated >= since(сравнение моментов времени с учётом смещения), и по ним делаетsearchjql = key in (...),fields = key,expand = changelog, по 100 ключей. Из журнала каждой задачи берёт записи сcreated >= since. Изменившихся задач нет — ни одного лишнего вызова Jira. Решение владельца: без второго «слепого» запроса поupdated >= …, отбор по уже выгруженному. - Агент 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 на пачку в каждом прогоне и отметка не сдвинулась бы никогда. - Агент v13: id поля.
field_id—fieldIdэлемента журнала, если Jira его отдаёт; иначе дляfieldtype = customагент ищет id по имени поля вGET /rest/api/2/field(один вызов наrefresh, только если такой элемент встретился); дляfieldtype = jira—fieldкак есть. Не нашлось —null. - Приём журнала —
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; нарушение — 422bad_historyс номером элемента и полем, пачка не применяется) и пишетINSERT OR IGNORE. Ответ{accepted, duplicates}. Отдельный маршрут, а не поле в/agent/state: журнал тянется после состояния, у него свои пачки, контракт состояния не меняется. - Итог журнала — в результате
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; MCPsync_logвыводит у прогона «журнал: принят <время>» или «журнал: нет». Текст ошибки журнала — в результате команды (batch_status). - Лента —
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, неверное время или вид — 422bad_requestс именем параметра. - История задачи — в
GET /issues/{key}. Полеchanges— все элементы журнала задачи (все виды, включаяother), в формате элемента ленты безsummary/status, новые сверху. Комментарии там уже есть отдельно. - MCP
changes. Параметрыhours(число),since,until,kinds,key. Безhoursиsince— с начала сегодняшнего дня по местному времени машины MCP. Привязка: в сессии проекта P —project = P; сkey— без проекта; без привязки и безkey— ответ «сессия не привязана к проекту…» (какissues). Вид ответа — по задачам (задачи — по времени последнего события, новые сверху): строка «КЛЮЧ тема — статус», под ней события по времени («ЧЧ:ММ автор: статус «В работе» → «Готово»»; дата перед временем, если событие не сегодня; описание — «описание изменено» без текста; срок — «срок 25.10 → 30.10» (дата — из начала*_valueвидаГГГГ-ММ-ДД, иначе строка как есть); комментарий — первые 200 символов с «…»). Пусто — «за период изменений нет».truncated— строка «показаны последние N событий, сузьте период». - MCP
issue. После комментариев — «изменения (N):» и 5 последних элементовchangesв том же виде, «ещё M», если их больше; без журнала — строки нет (у старых задач журнала может не быть). - Миграция 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_runhistory_at = NULL→ первыйrefreshпосле выкатки хаба — стартовая загрузка. - Порядок выкатки. Хаб можно выкатить раньше агента: агент 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). - Журнал соседей. Соседи, уже известные хабу, полным прогоном не перечитываются (поведение
refreshv12), поэтому их изменения попадают в журнал только при точечном обновлении. Принимаем: лента проекта показывает свои задачи.
Migration Plan
- PR хаба: схема v5, приём журнала,
historyв выдачеrefresh,GET /changes,changesвGET /issues/{key}, MCP. После merge —git pull, перезапуск хаба владельцем,/mcp→ reconnect. - PR агента: v13. После merge —
jira-hub agent publish ../jira-agent/src/agent.py;agent_statusпоказывает v13;min_agent_versionне поднимать (хаб работает и со старым агентом). - Живая проверка: полный
refresh(выдан сhistory.jql) → вresult.historyэлементы, в ленте события задач спринта; следующий полныйrefresh(выдан сhistory.since) → лишних вызовов Jira нет при отсутствии изменений; правка задачи в Jira → событие вchangesс автором и временем; лог агента — нет строк о неполном журнале (иначе — решение 8 под пересмотр).
Откат: агент — публикация v12 невозможна (только вверх), выкатывается v14 без журнала; хаб — предыдущий коммит,
таблица и колонки v5 остаются и не мешают (старый код их не читает), schema_version = 5 старый хаб не понизит.
Open Questions
Нет.
Decisions made without discussion
- 2026-10-22 — строка журнала — одно поле одной записи Jira (
issue_change), PK(history_id, seq). Обоснование: фильтр ленты по виду поля; идемпотентность по id Jira. Цена ошибки: еслиseqу одной записи Jira окажется неустойчивым между запросами, возможны дубли полей одной записи — видно в ленте, лечится сменой ключа на(history_id, field_name, to_value). - 2026-10-22 — сохраняются все поля журнала, лента по умолчанию показывает четыре вида. Обоснование: данные приходят бесплатно, веб и история задачи покажут больше. Цена ошибки: рост базы — текстами описаний.
- 2026-10-22 — отметка «с какого момента» — из
sync_run.history_atлюбого полного прогона с принятым журналом (без сравненияquery), сдвигается только им; запас 15 минут; после нового проекта —sinceиjqlвместе; неполныеrefreshбез отметки журнала не получают. Обоснование: анти-цель «молча теряет» (находки рецензента: новый проект не должен обнулять отметку прежних; хвост пакета не должен тянуть стартовую загрузку). Цена ошибки: при расхождении часов > 15 минут — пропуски; агенту два режима в одномrefresh. - 2026-10-22 — стартовая загрузка — JQL
sprint in openSprints() AND statusCategory != Done, журнал целиком, разово. Обоснование: формулировка владельца; активный спринт в зеркале не отличим от будущего;closed_statusesсо статусом, которого нет в Jira, дал бы 400 навсегда. Цена ошибки: статус с категорией не Done, который владелец считает закрытым, попадёт в стартовую загрузку (лишний журнал); канбан-проекты без стартовой истории; упавшая стартовая загрузка не повторяется (видно вbackfill_error). - 2026-10-22 — ошибка Jira или хаба (не 2xx) при чтении журнала не роняет
refreshи не повторяет команду (doneсhistory.error, отметка не сдвигается). Обоснование: зеркало важнее ленты; повтор команды перекачал бы состояние ради журнала; следующий прогон дочитает. Цена ошибки: при разовом 5xx лента отстаёт на один интервал автообновления. - 2026-10-22 — логическое имя поля вычисляет хаб, агент шлёт
field_idиfield_name. Обоснование: соответствие id полей — в настройках хаба. Цена ошибки: если Jira не отдаётfieldIdи агент не нашёл id по имени — плановые даты попадут вother; видно на живой проверке. - 2026-10-22 — комментарии в ленте — из
issue_commentпо новой колонкеcreated_at(UTC), а не событиями журнала. Обоснование: комментариев нет в журнале Jira; они уже приходят с автором и временем. Цена ошибки: удалённый в Jira комментарий пропадает из ленты. - 2026-10-22 — лента проекта — только свои задачи, без соседей. Обоснование: соседи — задачи других меток, в ленте
проекта это шум. Цена ошибки: изменение блокирующей задачи другого проекта не видно в ленте (видно через
key). - 2026-10-22 — MCP
changesпо умолчанию — с начала сегодняшнего дня по местному времени. Обоснование: «что закрылось сегодня» — первый сценарий владельца. Цена ошибки: лишний параметр в вопросе «за 2 часа» (hours: 2). - 2026-10-22 — история задачи — полем
changesвGET /issues/{key}, а не отдельным маршрутом. Обоснование: так уже отдаются комментарии; карточка и MCPissueполучают всё одним запросом. Цена ошибки: тяжелее ответ карточки у задач с длинной историей. - 2026-10-22 — событие «задача создана» не входит в ленту. Обоснование: владелец его не называл, в журнале Jira его
нет. Цена ошибки: новые задачи дня не видны в ленте; добавляется по
issue.createdбез агента. - 2026-10-22 — журнал приёмом
POST /agent/historyпачками по 500 элементов; ответ{accepted, duplicates}. Обоснование: не менять контракт/agent/state; размер пачки — по опыту реле с состоянием. Цена ошибки: пачки описаний тяжелее пачек состояния — при сбоях реле уменьшить. - 2026-10-22 — неполный журнал задачи не держит отметку; ключи — в
result.history.incompleteи в логе. Обоснование: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда. Цена ошибки: потерянные старые записи у задач с очень длинной историей; видно в результате команды и на живой проверке. - 2026-10-22 — агент отбрасывает элементы, которые хаб отверг бы (пустые
created,field_name, нетid), с записью в лог. Обоснование: один кривой элемент иначе давал бы 422 на пачку в каждом прогоне. Цена ошибки: такой элемент не попадёт в ленту. - 2026-10-22 — элемент ленты несёт и строки, и сырые значения (
from_value/to_value); MCP показывает даты по значению. Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее. - 2026-10-22 —
sync_logпоказывает, принят ли журнал прогона (history_at). Обоснование: дизайн обещал видимость ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP. - 2026-10-22 — R-1: шаги миграции v4 → v5 добавляют колонку, только если её нет (
_add_column). Обоснование: базы v1/v2 получаютissue_commentизSCHEMAуже сcreated_at, голыйALTERпадал бы «duplicate column». Цена ошибки: нет — для базы v4 поведение то же. - 2026-10-22 — R-2: прежний тест формата
sync_logпереписан на строку с «, журнал: нет». Обоснование: спекаmcpдобавляет журнал к каждой строке. Цена ошибки: нет. - 2026-10-22 — R-7 (по I-1 финального ревью): агент выполняет стартовую часть журнала первой, регулярная не
запрашивает уже прочитанные задачи; задача помечается прочитанной только после разбора её журнала. Обоснование:
при
history = {since, jql}задача из обеих выборок теряла записи старшеsince(их отсекала регулярная часть, а стартовая её пропускала), хотя полный журнал уже был получен; спекаagentуточнена. Цена ошибки: нет лишних вызовов Jira; если стартовая часть упала на задаче, её добирает регулярная (только записи послеsince). - 2026-10-22 — R-8 (по M-1/M-2 финального ревью, подняты до Important по эффекту): время за краем диапазона
datetime— «не разобралось» (None), а не исключение;GET /changesотвечает 422 на время без смещения, MCPchangesсчитает такое время местным. Обоснование: исключение роняло миграцию (хаб не стартует) или навсегда стопорило отметку журнала; время без смещения из чата молча сдвигало окно ленты на 3 часа. Цена ошибки: запрос API без смещения теперь 422 — интерфейсов, которые так шлют, нет.