Журнал изменений задач: лента и история в хабе и MCP (#70) #74
@ -7,10 +7,11 @@
|
|||||||
| Модуль | Ответственность | Зависит от |
|
| Модуль | Ответственность | Зависит от |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
|
| `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` |
|
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
|
||||||
| `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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` |
|
| `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`) |
|
| `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` |
|
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
|
||||||
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
|
| `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`); доска отдаёт наружу только
|
боковой панели, отслеживание пакета) — `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`.
|
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
|
||||||
- Вложение: файл → `POST /files` → `files/<sha256>` → `ensure_attachment {key, file, filename}` в пакете (`queue._check_setup` проверяет, что файл есть) → `claim` (хаб дописывает `size`, в базе `params` без него) → агент `GET /agent/files/{sha256}` → Jira; уборка `files/` — из `tick` раз в сутки.
|
- Вложение: файл → `POST /files` → `files/<sha256>` → `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`.
|
- Создание проекта: `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`.
|
- Чтение 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 хаба → ответ файлом.
|
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
|
||||||
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
|
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
|
||||||
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
|
- Зеркало пишет только `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`.
|
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
|
||||||
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
|
- `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`).
|
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
|
||||||
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
|
- `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` и своих копий не держит.
|
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит.
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
|
||||||
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
|
||||||
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
|
- Хаб не создаёт команды сам: повтор после 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/*`, клиентский — остальное.
|
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|||||||
295
docs/superpowers/plans/2026-10-22-change-history-hub.md
Normal file
295
docs/superpowers/plans/2026-10-22-change-history-hub.md
Normal file
@ -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 <noreply@anthropic.com>`.
|
||||||
|
- Зелёным считается только весь набор: `uv run pytest` (фронт не меняется; `make check` — в финальной задаче).
|
||||||
|
- Любое решение исполнителя, не записанное в спеке или плане, дописывается в `openspec/changes/change-history/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки) с маркером `R-n`. Находки ревью задачи n — `T<n>.<k>`.
|
||||||
|
|
||||||
|
## 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 = <now> 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
openspec/changes/change-history/.openspec.yaml
Normal file
2
openspec/changes/change-history/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-22
|
||||||
233
openspec/changes/change-history/design.md
Normal file
233
openspec/changes/change-history/design.md
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
`main` 7911dc7, схема v4, агент v12. Устройство, на которое опираются правки:
|
||||||
|
|
||||||
|
- **Зеркало** — `mirror.apply_state` делает upsert строки `issue` (прежние значения теряются) и заменяет комментарии
|
||||||
|
задачи присланными (`issue_comment`, время `created`/`updated` — строка Jira как есть, например
|
||||||
|
`2026-10-22T13:05:11.000+0300`). Поле `jira_updated` (Jira `updated`) уже приходит в состоянии.
|
||||||
|
- **Полный прогон** — `refresh {jql: base_jql}` (`queue.is_full_refresh`); его ставят автообновление
|
||||||
|
(`queue.maybe_auto_refresh`, по умолчанию раз в 60 минут, `auto_refresh_minutes`), `POST /refresh {full}` и MCP
|
||||||
|
`refresh full`. Хвост пакета — `refresh {keys}` по ключам пакета. `base_jql` вычисляется из проектов и меняется при
|
||||||
|
создании проекта.
|
||||||
|
- **Выдача** — `queue.deliver_params` дописывает параметры при выдаче (у `refresh` — `boards`), в базе `params` без них.
|
||||||
|
`report_result` по `refresh` пишет `sync_run.finished_at` и типы досок из `result.boards`.
|
||||||
|
- **Агент** (`jira-agent/src/agent.py`, v12, Python 3.9 stdlib): `do_refresh` → `search_all(jql)` с полями зеркала
|
||||||
|
(включая `updated`), дочитывание соседей (`search_tolerant`, `key in (...)`), имена спринтов, отправка
|
||||||
|
`POST /agent/state` пачками по 100. Неизвестные параметры команды агент игнорирует (`p.get`).
|
||||||
|
- **Спринт в зеркале** — имя активного или будущего спринта доски проекта; у задач закрытых спринтов `sprint = null`.
|
||||||
|
Активный и будущий в зеркале не различаются.
|
||||||
|
- **MCP** — `mcp_server.py` ходит только через `client`; `issue` форматирует `GET /issues/{key}` (комментарии — 5
|
||||||
|
последних).
|
||||||
|
- **Время** — в базе ISO-8601 UTC (`db.now()`), кроме дат без времени и времени комментариев.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:** журнал изменений задач из Jira в хабе; лента событий за период в API и MCP; история задачи в
|
||||||
|
`GET /issues/{key}` и MCP `issue`; стартовая загрузка по незакрытым задачам текущего спринта; регулярная загрузка только
|
||||||
|
по изменившимся задачам; ни одно изменение не теряется молча.
|
||||||
|
|
||||||
|
**Non-Goals и Anti-Goals:** см. proposal.md.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Таблица `issue_change`, схема v5.** Колонки: `history_id` (TEXT, id записи журнала Jira), `seq` (INTEGER,
|
||||||
|
порядковый номер поля в записи, с 0), `key`, `at` (ISO-8601 UTC — время записи, приведённое хабом), `created` (время
|
||||||
|
Jira как есть), `author` (отображаемое имя, иначе логин, иначе `NULL`), `field` (логическое имя, см. решение 3),
|
||||||
|
`field_id` (id поля Jira или `NULL`), `field_name` (имя поля Jira как есть), `from_value`, `from_string`, `to_value`,
|
||||||
|
`to_string` (как есть, могут быть `NULL`), `fetched_at`. PK `(history_id, seq)`; индексы `(at)` и `(key, at)`.
|
||||||
|
Запись — `INSERT OR IGNORE`: повтор записи журнала ничего не меняет. Пишет таблицу только приём журнала от агента;
|
||||||
|
API и MCP — только читают (как зеркало).
|
||||||
|
*Почему по строке на поле, а не на запись журнала:* лента фильтрует по виду поля, одна запись Jira часто меняет
|
||||||
|
статус и резолюцию разом.
|
||||||
|
2. **Время комментариев в UTC.** Колонка `issue_comment.created_at` (ISO-8601 UTC, приведённое из `created`; не
|
||||||
|
разобралось — `NULL`), заполняется в `apply_state`; миграция v4 → v5 заполняет её у имеющихся строк. Строка `created`
|
||||||
|
остаётся как есть (её показывают интерфейсы). Время журнала приводится так же (`at`). Разбор: ISO-8601 со смещением
|
||||||
|
`+0300` / `+03:00` / `Z`, с дробной частью или без; без смещения — UTC.
|
||||||
|
3. **Логическое имя поля.** Хаб вычисляет `field` при приёме: `status`, `description`, `summary`, `assignee`,
|
||||||
|
`priority`, `duedate`, `labels`, `resolution` — по `field_id`, а без него — по `field_name` (встроенные поля Jira
|
||||||
|
называются так же); `planned_start`, `planned_end`, `epic_link` — по `field_id`, совпавшему с `field_ids` /
|
||||||
|
`epic_link.field_id` настроек; остальное — `field_name` как есть. Хаб, а не агент: соответствие логических имён и id
|
||||||
|
полей уже живёт в настройках хаба, агент шлёт сырые данные.
|
||||||
|
4. **Виды событий ленты.** `status` — поле `status`; `description` — поле `description`; `dates` — поля `duedate`,
|
||||||
|
`planned_start`, `planned_end`; `comment` — комментарий с `created_at` в окне (из `issue_comment`, не из журнала);
|
||||||
|
`other` — прочие поля журнала. По умолчанию лента отдаёт `status`, `description`, `dates`, `comment`; `other` — только
|
||||||
|
по запросу (`kinds=all` или явно). Остальные поля журнала сохраняются всегда — приходят бесплатно, понадобятся вебу.
|
||||||
|
5. **Отметка «с какого момента» — из `sync_run`, без новой настройки.** Колонка `sync_run.history_at` (время приёма
|
||||||
|
итога журнала, `NULL` — журнал по прогону не принят). Хаб проставляет её только у полного прогона (`refresh` по
|
||||||
|
базовой JQL), завершившегося `done` с `result.history` без `error` (решение 11). Отметка — `started_at` последнего
|
||||||
|
`sync_run` с `history_at IS NOT NULL` **независимо от `query`** (новый проект не обнуляет отметку прежних). Параметр
|
||||||
|
`history` при выдаче `refresh`:
|
||||||
|
- полный прогон, отметка есть — `{since: <отметка − 15 минут>}` (UTC, ISO-8601 с `Z`); если `query` прогона-отметки
|
||||||
|
не равна текущей `base_jql` (после него заведён проект) — ещё и `jql` стартовой загрузки (решение 6): прежние
|
||||||
|
проекты дочитываются от отметки, новый получает стартовую историю;
|
||||||
|
- полный прогон, отметки нет — `{jql: <стартовая>}`;
|
||||||
|
- `refresh {keys}` и `refresh` по небазовой JQL — `{since}`, если отметка есть, иначе `history` не дописывается
|
||||||
|
(стартовую загрузку делает только полный прогон).
|
||||||
|
Запас 15 минут покрывает расхождение часов Mac mini и сервера Jira и время самого прогона; повторы отбрасываются по
|
||||||
|
PK. Отметка сдвигается **только** полным прогоном с принятым журналом: прогон упал, регулярная часть журнала не
|
||||||
|
дочитана, агент старый — следующий прогон возьмёт прежнюю отметку и дочитает пропущенное (анти-цель «молча теряет»).
|
||||||
|
*Отвергнуто:* отдельная настройка-отметка (её правят руками и через `settings_set`, сдвиг надо делать атомарно с
|
||||||
|
итогом прогона); сравнение `jira_updated` в хабе с ответом агенту списком изменившихся (хаб уже перезаписал строку, если
|
||||||
|
журнал потом не дочитан — изменение потеряно); отметка по `query` = текущая `base_jql` (после нового проекта прежние
|
||||||
|
проекты теряли изменения вне спринта между отметкой и прогоном — находка рецензента).
|
||||||
|
6. **Стартовая загрузка.** `history.jql = (<base_jql>) AND sprint in openSprints() AND statusCategory != Done`. Агент
|
||||||
|
тянет журнал этих задач целиком (без отсечения по времени). Решение владельца: «максимум незакрытые в текущем
|
||||||
|
спринте». `openSprints()` — активные спринты, будущие не входят. Категория статуса, а не настройка `closed_statuses`:
|
||||||
|
статус из настройки, которого нет в Jira, даёт 400 на всю JQL, и стартовая загрузка не прошла бы никогда. Стартовая
|
||||||
|
загрузка — разовая и «по возможности»: её ошибка не мешает отметке (решение 11) и не повторяется. Проект на
|
||||||
|
канбан-доске без спринтов в стартовую загрузку не попадает — его история начнётся с первого регулярного прогона.
|
||||||
|
7. **Агент v13: регулярный режим (`history.since`).** После отправки состояния (все пачки `POST /agent/state`) агент
|
||||||
|
отбирает из выгруженных задач (база и соседи) те, у кого Jira `updated >= since` (сравнение моментов времени с
|
||||||
|
учётом смещения), и по ним делает `search` `jql = key in (...)`, `fields = key`, `expand = changelog`, по 100 ключей.
|
||||||
|
Из журнала каждой задачи берёт записи с `created >= since`. Изменившихся задач нет — ни одного лишнего вызова Jira.
|
||||||
|
Решение владельца: без второго «слепого» запроса по `updated >= …`, отбор по уже выгруженному.
|
||||||
|
8. **Агент v13: неполный журнал.** Если у задачи `changelog.total` больше числа записей в ответе `search`, агент дочитывает
|
||||||
|
журнал `GET /rest/api/2/issue/{key}?fields=key&expand=changelog`; если и там неполный — передаёт что есть, пишет в
|
||||||
|
лог (ключ, сколько получено из скольких) и добавляет ключ в `result.history.incomplete`. Отметку неполный журнал не
|
||||||
|
держит: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда; неполнота видна в результате
|
||||||
|
команды. Jira Server/DC, по ожиданию, отдаёт журнал целиком; проверить на живом агенте (план, живая проверка).
|
||||||
|
Элемент, который хаб отверг бы (пустые `created` или `field_name`, запись без `id`), агент отбрасывает с записью в
|
||||||
|
лог — иначе один кривой элемент давал бы 422 на пачку в каждом прогоне и отметка не сдвинулась бы никогда.
|
||||||
|
9. **Агент v13: id поля.** `field_id` — `fieldId` элемента журнала, если Jira его отдаёт; иначе для `fieldtype = custom`
|
||||||
|
агент ищет id по имени поля в `GET /rest/api/2/field` (один вызов на `refresh`, только если такой элемент встретился);
|
||||||
|
для `fieldtype = jira` — `field` как есть. Не нашлось — `null`.
|
||||||
|
10. **Приём журнала — `POST /agent/history`.** Тело `{command_id, changes: [...]}`, элемент — `{history_id, seq, key,
|
||||||
|
created, author, field_id, field_name, from, from_string, to, to_string}`. Агент шлёт пачками по 500 элементов;
|
||||||
|
хаб проверяет форму всей пачки до записи (`history_id`, `key`, `created`, `field_name` — непустые строки, `seq` —
|
||||||
|
целое ≥ 0, остальные — строка или `null`; нарушение — 422 `bad_history` с номером элемента и полем, пачка не
|
||||||
|
применяется) и пишет `INSERT OR IGNORE`. Ответ `{accepted, duplicates}`. Отдельный маршрут, а не поле в
|
||||||
|
`/agent/state`: журнал тянется после состояния, у него свои пачки, контракт состояния не меняется.
|
||||||
|
11. **Итог журнала — в результате `refresh`.** `result.history = {issues: <задач с журналом>, changes: <элементов
|
||||||
|
отправлено>, incomplete: [ключи с неполным журналом]}` плюс, при ошибке, `error` (регулярная часть, `since`) или
|
||||||
|
`backfill_error` (стартовая загрузка, `jql`). Ответ не 2xx от Jira или хаба на шаге журнала (`HttpError`, у хаба — с
|
||||||
|
префиксом «хаб: ») не роняет `refresh`: агент сразу, без повтора команды, возвращает `done` с текстом ошибки — зеркало
|
||||||
|
уже обновлено, ломать и перекачивать его ради журнала нельзя (анти-цель); повторит следующий прогон, отметка ведь
|
||||||
|
не сдвинулась. Обрыв связи с Jira или хабом (`ConnectionError`) на шаге журнала — как в остальном `refresh` (повтор,
|
||||||
|
затем `failed`). Без `history` в параметрах агент журнал не читает и ключа `history` в результате нет. Хаб ставит
|
||||||
|
`sync_run.history_at` при `done` полного прогона и `history` без `error`; `backfill_error` и `incomplete` отметку не
|
||||||
|
держат (стартовая загрузка разовая — решение 6; неполнота — решение 8).
|
||||||
|
11a. **Журнал в `sync_log`.** `GET /sync-runs` отдаёт `history_at`; MCP `sync_log` выводит у прогона «журнал: принят
|
||||||
|
<время>» или «журнал: нет». Текст ошибки журнала — в результате команды (`batch_status`).
|
||||||
|
12. **Лента — `GET /changes`.** Параметры: `since`, `until` (ISO-8601 со смещением или `Z`; без `until` — сейчас; без
|
||||||
|
`since` — `until − 24 ч`), `project` (только свои задачи проекта; соседи — нет: в ленте проекта чужие задачи —
|
||||||
|
шум), `key` (одна задача, без ограничения проектом), `kinds` (через запятую из `status`, `description`, `dates`,
|
||||||
|
`comment`, `other`, или `all`; по умолчанию все, кроме `other`), `limit` (по умолчанию 500, до 2000). Ответ
|
||||||
|
`{since, until, changes: [...], truncated}`, элемент — `{at, key, summary, status, kind, field, author, from, to,
|
||||||
|
from_value, to_value}` (`from`/`to` — `from_string`/`to_string`, а если они `null` — `from_value`/`to_value`;
|
||||||
|
`from_value`/`to_value` — сырые значения журнала: у дат Jira строка журнала может быть локализованной, а значение —
|
||||||
|
ISO, им пользуется MCP; у комментария `from = null`, `to` — текст, `field = "comment"`, `*_value = null`); порядок — `at` по убыванию, затем `key`, `history_id`, `seq`. Неизвестный проект
|
||||||
|
— 404, неверное время или вид — 422 `bad_request` с именем параметра.
|
||||||
|
13. **История задачи — в `GET /issues/{key}`.** Поле `changes` — все элементы журнала задачи (все виды, включая `other`),
|
||||||
|
в формате элемента ленты без `summary`/`status`, новые сверху. Комментарии там уже есть отдельно.
|
||||||
|
14. **MCP `changes`.** Параметры `hours` (число), `since`, `until`, `kinds`, `key`. Без `hours` и `since` — с начала
|
||||||
|
сегодняшнего дня по местному времени машины MCP. Привязка: в сессии проекта P — `project = P`; с `key` — без
|
||||||
|
проекта; без привязки и без `key` — ответ «сессия не привязана к проекту…» (как `issues`). Вид ответа — по задачам
|
||||||
|
(задачи — по времени последнего события, новые сверху): строка «КЛЮЧ тема — статус», под ней события по времени
|
||||||
|
(«ЧЧ:ММ автор: статус «В работе» → «Готово»»; дата перед временем, если событие не сегодня; описание — «описание
|
||||||
|
изменено» без текста; срок — «срок 25.10 → 30.10» (дата — из начала `*_value` вида `ГГГГ-ММ-ДД`, иначе строка как
|
||||||
|
есть); комментарий — первые 200 символов с «…»). Пусто — «за период
|
||||||
|
изменений нет». `truncated` — строка «показаны последние N событий, сузьте период».
|
||||||
|
15. **MCP `issue`.** После комментариев — «изменения (N):» и 5 последних элементов `changes` в том же виде, «ещё M», если
|
||||||
|
их больше; без журнала — строки нет (у старых задач журнала может не быть).
|
||||||
|
16. **Миграция v4 → v5.** В той же транзакции с копией базы, что и прежние: `issue_change` и индексы создаёт `SCHEMA`;
|
||||||
|
шаг — `ALTER TABLE issue_comment ADD COLUMN created_at TEXT`, `ALTER TABLE sync_run ADD COLUMN history_at TEXT`,
|
||||||
|
заполнение `issue_comment.created_at` разбором `created` (Python-шаг, как `migrate_v3`). У имеющихся `sync_run`
|
||||||
|
`history_at = NULL` → первый `refresh` после выкатки хаба — стартовая загрузка.
|
||||||
|
17. **Порядок выкатки.** Хаб можно выкатить раньше агента: агент v12 игнорирует `history`, результат без `history` —
|
||||||
|
отметка не сдвигается, `history_at` пуст, каждый полный `refresh` выдаётся со стартовой JQL, но агент её не исполняет.
|
||||||
|
После публикации v13 первый полный прогон делает стартовую загрузку. Агент v13 со старым хабом (без `history` в
|
||||||
|
параметрах) журнал не читает.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **Объём стартовой загрузки.** Журнал задач открытого спринта целиком; у задач с длинной историей описания — большие
|
||||||
|
тексты (`from_string`/`to_string` описаний), идут через общую папку. Пачки по 500 элементов; если пачка окажется
|
||||||
|
слишком тяжёлой для реле — уменьшить в агенте.
|
||||||
|
- **Расхождение часов > 15 минут** между Mac mini и сервером Jira даёт пропуски. Цена — потеря событий без сигнала;
|
||||||
|
принимаем, часы синхронизируются по NTP.
|
||||||
|
- **Удалённый комментарий** исчезает из ленты задним числом (лента читает `issue_comment`).
|
||||||
|
- **Журнал соседей.** Соседи, уже известные хабу, полным прогоном не перечитываются (поведение `refresh` v12), поэтому
|
||||||
|
их изменения попадают в журнал только при точечном обновлении. Принимаем: лента проекта показывает свои задачи.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. PR хаба: схема v5, приём журнала, `history` в выдаче `refresh`, `GET /changes`, `changes` в `GET /issues/{key}`,
|
||||||
|
MCP. После merge — `git pull`, перезапуск хаба владельцем, `/mcp` → reconnect.
|
||||||
|
2. PR агента: v13. После merge — `jira-hub agent publish ../jira-agent/src/agent.py`; `agent_status` показывает v13;
|
||||||
|
`min_agent_version` не поднимать (хаб работает и со старым агентом).
|
||||||
|
3. Живая проверка: полный `refresh` (выдан с `history.jql`) → в `result.history` элементы, в ленте события задач
|
||||||
|
спринта; следующий полный `refresh` (выдан с `history.since`) → лишних вызовов Jira нет при отсутствии изменений; правка задачи в Jira → событие в
|
||||||
|
`changes` с автором и временем; лог агента — нет строк о неполном журнале (иначе — решение 8 под пересмотр).
|
||||||
|
|
||||||
|
Откат: агент — публикация v12 невозможна (только вверх), выкатывается v14 без журнала; хаб — предыдущий коммит,
|
||||||
|
таблица и колонки v5 остаются и не мешают (старый код их не читает), `schema_version = 5` старый хаб не понизит.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
1. 2026-10-22 — **строка журнала — одно поле одной записи Jira (`issue_change`), PK `(history_id, seq)`.** Обоснование:
|
||||||
|
фильтр ленты по виду поля; идемпотентность по id Jira. Цена ошибки: если `seq` у одной записи Jira окажется
|
||||||
|
неустойчивым между запросами, возможны дубли полей одной записи — видно в ленте, лечится сменой ключа на
|
||||||
|
`(history_id, field_name, to_value)`.
|
||||||
|
2. 2026-10-22 — **сохраняются все поля журнала, лента по умолчанию показывает четыре вида.** Обоснование: данные
|
||||||
|
приходят бесплатно, веб и история задачи покажут больше. Цена ошибки: рост базы — текстами описаний.
|
||||||
|
3. 2026-10-22 — **отметка «с какого момента» — из `sync_run.history_at` любого полного прогона с принятым журналом
|
||||||
|
(без сравнения `query`), сдвигается только им; запас 15 минут; после нового проекта — `since` и `jql` вместе;
|
||||||
|
неполные `refresh` без отметки журнала не получают.** Обоснование: анти-цель «молча теряет» (находки рецензента:
|
||||||
|
новый проект не должен обнулять отметку прежних; хвост пакета не должен тянуть стартовую загрузку). Цена ошибки:
|
||||||
|
при расхождении часов > 15 минут — пропуски; агенту два режима в одном `refresh`.
|
||||||
|
4. 2026-10-22 — **стартовая загрузка — JQL `sprint in openSprints() AND statusCategory != Done`, журнал целиком,
|
||||||
|
разово.** Обоснование: формулировка владельца; активный спринт в зеркале не отличим от будущего; `closed_statuses`
|
||||||
|
со статусом, которого нет в Jira, дал бы 400 навсегда. Цена ошибки: статус с категорией не Done, который владелец
|
||||||
|
считает закрытым, попадёт в стартовую загрузку (лишний журнал); канбан-проекты без стартовой истории; упавшая
|
||||||
|
стартовая загрузка не повторяется (видно в `backfill_error`).
|
||||||
|
5. 2026-10-22 — **ошибка Jira или хаба (не 2xx) при чтении журнала не роняет `refresh` и не повторяет команду**
|
||||||
|
(`done` с `history.error`, отметка не сдвигается). Обоснование: зеркало важнее ленты; повтор команды перекачал бы
|
||||||
|
состояние ради журнала; следующий прогон дочитает. Цена ошибки: при разовом 5xx лента отстаёт на один интервал
|
||||||
|
автообновления.
|
||||||
|
6. 2026-10-22 — **логическое имя поля вычисляет хаб, агент шлёт `field_id` и `field_name`.** Обоснование: соответствие id
|
||||||
|
полей — в настройках хаба. Цена ошибки: если Jira не отдаёт `fieldId` и агент не нашёл id по имени — плановые даты
|
||||||
|
попадут в `other`; видно на живой проверке.
|
||||||
|
7. 2026-10-22 — **комментарии в ленте — из `issue_comment` по новой колонке `created_at` (UTC), а не событиями
|
||||||
|
журнала.** Обоснование: комментариев нет в журнале Jira; они уже приходят с автором и временем. Цена ошибки:
|
||||||
|
удалённый в Jira комментарий пропадает из ленты.
|
||||||
|
8. 2026-10-22 — **лента проекта — только свои задачи, без соседей.** Обоснование: соседи — задачи других меток, в ленте
|
||||||
|
проекта это шум. Цена ошибки: изменение блокирующей задачи другого проекта не видно в ленте (видно через `key`).
|
||||||
|
9. 2026-10-22 — **MCP `changes` по умолчанию — с начала сегодняшнего дня по местному времени.** Обоснование: «что
|
||||||
|
закрылось сегодня» — первый сценарий владельца. Цена ошибки: лишний параметр в вопросе «за 2 часа» (`hours: 2`).
|
||||||
|
10. 2026-10-22 — **история задачи — полем `changes` в `GET /issues/{key}`, а не отдельным маршрутом.** Обоснование: так
|
||||||
|
уже отдаются комментарии; карточка и MCP `issue` получают всё одним запросом. Цена ошибки: тяжелее ответ карточки
|
||||||
|
у задач с длинной историей.
|
||||||
|
11. 2026-10-22 — **событие «задача создана» не входит в ленту.** Обоснование: владелец его не называл, в журнале Jira его
|
||||||
|
нет. Цена ошибки: новые задачи дня не видны в ленте; добавляется по `issue.created` без агента.
|
||||||
|
12. 2026-10-22 — **журнал приёмом `POST /agent/history` пачками по 500 элементов; ответ `{accepted, duplicates}`.**
|
||||||
|
Обоснование: не менять контракт `/agent/state`; размер пачки — по опыту реле с состоянием. Цена ошибки: пачки
|
||||||
|
описаний тяжелее пачек состояния — при сбоях реле уменьшить.
|
||||||
|
13. 2026-10-22 — **неполный журнал задачи не держит отметку; ключи — в `result.history.incomplete` и в логе.**
|
||||||
|
Обоснование: если Jira обрезает журнал всегда, удержание остановило бы ленту навсегда. Цена ошибки: потерянные
|
||||||
|
старые записи у задач с очень длинной историей; видно в результате команды и на живой проверке.
|
||||||
|
14. 2026-10-22 — **агент отбрасывает элементы, которые хаб отверг бы** (пустые `created`, `field_name`, нет `id`), с
|
||||||
|
записью в лог. Обоснование: один кривой элемент иначе давал бы 422 на пачку в каждом прогоне. Цена ошибки: такой
|
||||||
|
элемент не попадёт в ленту.
|
||||||
|
15. 2026-10-22 — **элемент ленты несёт и строки, и сырые значения (`from_value`/`to_value`); MCP показывает даты по
|
||||||
|
значению.** Обоснование: строка журнала у дат Jira может быть локализованной. Цена ошибки: ответ чуть тяжелее.
|
||||||
|
16. 2026-10-22 — **`sync_log` показывает, принят ли журнал прогона (`history_at`).** Обоснование: дизайн обещал видимость
|
||||||
|
ошибок журнала; текст ошибки — в результате команды. Цена ошибки: одна колонка и строка в MCP.
|
||||||
|
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 — интерфейсов, которые так шлют, нет.
|
||||||
50
openspec/changes/change-history/proposal.md
Normal file
50
openspec/changes/change-history/proposal.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Зеркало хаба хранит только текущее состояние задачи: при каждом обновлении строка `issue` перезаписывается, прежние
|
||||||
|
значения теряются. Владелец хочет быстро видеть, **что произошло с задачами за последние часы или за сегодня** — что
|
||||||
|
закрылось, у чего поменялось описание, где появился комментарий, у чего сдвинулись сроки, — и **как менялась конкретная
|
||||||
|
задача**.
|
||||||
|
|
||||||
|
Решения владельца 22.10.2026 (brainstorming, jira-hub#70, jira-agent#39):
|
||||||
|
- **источник — журнал изменений Jira (changelog)**, который тянет агент: точное время и автор каждого изменения, а не
|
||||||
|
разница снимков хаба;
|
||||||
|
- **события ленты**: смена статуса (в том числе закрытие), правка описания, новый комментарий, изменение сроков;
|
||||||
|
- **регулярно — без отдельного «слепого» запроса**: агент из уже выгруженных задач отбирает изменившиеся с прошлого
|
||||||
|
раза (по `updated`) и только по ним тянет журнал;
|
||||||
|
- **стартовая загрузка — только незакрытые задачи текущего спринта**, не всё зеркало;
|
||||||
|
- **где смотреть**: MCP (спросить Claude «что изменилось за 2 часа») и, позже, веб-приложение — страница «Лента» и
|
||||||
|
история в карточке задачи;
|
||||||
|
- **веб-приложение в этом change не трогаем**: идёт редизайн (#69), веб-часть — отдельной задачей после него.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Журнал изменений в хабе.** Новая таблица `issue_change` (схема v5): одна строка — одно изменённое поле в одной
|
||||||
|
записи журнала Jira (id записи, порядковый номер поля в ней, ключ задачи, время, автор, поле, было/стало). Повторно
|
||||||
|
присланные записи отбрасываются по id, поэтому перекрытие окон безопасно. Время комментариев приводится к UTC
|
||||||
|
(`issue_comment.created_at`), чтобы ленту можно было резать по времени.
|
||||||
|
- **Отметка «с какого момента».** Хаб при выдаче `refresh` дописывает параметр `history`: `{since}` — время начала
|
||||||
|
последнего полного прогона, по которому журнал принят, минус запас; если такого прогона нет — `{jql}` стартовой
|
||||||
|
загрузки (незакрытые задачи открытых спринтов из базовой JQL).
|
||||||
|
- **Агент v13** (отдельный PR в jira-agent): после выгрузки состояния отбирает задачи с `updated >= since` и одним
|
||||||
|
`search` с `expand=changelog` на 100 ключей получает их журнал; в режиме стартовой загрузки — отдельный `search` по
|
||||||
|
`history.jql`. Записи отправляет хабу новым маршрутом `POST /agent/history`; итог — в результате `refresh`.
|
||||||
|
- **Лента в API.** `GET /changes` — события за период (журнал + новые комментарии), с разрезом по проекту, фильтром по
|
||||||
|
видам событий и по задаче.
|
||||||
|
- **MCP.** Новый инструмент `changes` (лента за период: по умолчанию — с начала сегодняшнего дня; `hours`, `since`,
|
||||||
|
`until`, `kinds`, `key`); `issue` показывает 5 последних изменений задачи.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Веб-приложение: страница «Лента», история в карточке задачи — отдельной задачей после редизайна (#69).
|
||||||
|
- Событие «задача создана» в ленте — в журнале Jira его нет; можно добавить позже по `issue.created`.
|
||||||
|
- Правка и удаление комментария — их нет в журнале Jira; в ленте только новые комментарии, пока они есть в Jira.
|
||||||
|
- История до стартовой загрузки по задачам вне открытых спринтов и по закрытым задачам.
|
||||||
|
- Срок хранения, чистка журнала — пока храним всё.
|
||||||
|
- Отдельный запрос журнала без `refresh` или своя периодичность — журнал приходит только вместе с обновлением зеркала.
|
||||||
|
|
||||||
|
## Anti-Goals
|
||||||
|
|
||||||
|
- Лента молча теряет изменения: например, агент не смог дочитать журнал, а отметка «с какого момента» всё равно
|
||||||
|
сдвинулась вперёд.
|
||||||
|
- Каждое автообновление тянет журнал всех задач зеркала и заметно замедляет обновление или упирается в лимиты Jira.
|
||||||
|
- Старый агент (≤ v12) или сбой чтения журнала ломает обновление зеркала.
|
||||||
74
openspec/changes/change-history/specs/agent/spec.md
Normal file
74
openspec/changes/change-history/specs/agent/spec.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
## 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: Задача в обеих выборках
|
||||||
|
- **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`
|
||||||
|
- **THEN** у элемента `field_id = "customfield_10201"`; `GET /field` вызван один раз за `refresh`
|
||||||
|
|
||||||
|
#### Scenario: Ошибка Jira при чтении журнала
|
||||||
|
- **WHEN** `search` с `expand = changelog` для регулярной части ответил 500
|
||||||
|
- **THEN** состояние в хабе обновлено; `refresh` — `done` с `history.error`, повтора команды нет; в логе — ошибка
|
||||||
|
|
||||||
|
#### Scenario: Ошибка стартовой загрузки
|
||||||
|
- **WHEN** `history = {since: …, jql: …}`, `search` по `history.jql` ответил 400, у X `updated` позже `since`
|
||||||
|
- **THEN** журнал X отправлен; `refresh` — `done`, в `history` — `backfill_error` с текстом Jira, `error` нет
|
||||||
|
|
||||||
|
#### Scenario: Неполный журнал
|
||||||
|
- **WHEN** у X `changelog.total = 250`, и `search`, и `GET /issue/X?expand=changelog` вернули 100 записей
|
||||||
|
- **THEN** отправлены 100 записей X; в логе «X: получено 100 из 250»; `history.incomplete = ["X"]`
|
||||||
|
|
||||||
|
#### Scenario: Хаб без журнала
|
||||||
|
- **WHEN** `refresh {jql: base}` без `history`
|
||||||
|
- **THEN** журнал не читается; в результате нет ключа `history`
|
||||||
43
openspec/changes/change-history/specs/commands/spec.md
Normal file
43
openspec/changes/change-history/specs/commands/spec.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Журнал изменений в refresh
|
||||||
|
Отметка журнала — `started_at` последнего `sync_run` с `history_at IS NOT NULL` (любой `query`). JQL стартовой загрузки
|
||||||
|
— `(<базовая JQL>) AND sprint in openSprints() AND statusCategory != Done`. Хаб SHALL при каждой выдаче `refresh`
|
||||||
|
(первой и повторной) дописывать в параметры `history` (в базе `params` без него, как `boards`):
|
||||||
|
- полный прогон (`refresh` по базовой JQL), отметка есть — `{since: <отметка − 15 минут, ISO-8601 UTC с Z>}`; если
|
||||||
|
`query` прогона-отметки не равна текущей базовой JQL — также `jql: <JQL стартовой загрузки>`;
|
||||||
|
- полный прогон, отметки нет — `{jql: <JQL стартовой загрузки>}`;
|
||||||
|
- `refresh {keys}` и `refresh` по небазовой JQL — `{since: …}`, если отметка есть; иначе `history` не дописывается;
|
||||||
|
- без базовой JQL (нет проектов) — `history` не дописывается.
|
||||||
|
|
||||||
|
Результат `refresh` MAY содержать `history` (см. `agent`, «Журнал изменений»); хаб SHALL принимать результат и без
|
||||||
|
него. Отметку (`sync_run.history_at`) хаб SHALL проставлять по правилам `data-model`, «Отметка журнала в журнале
|
||||||
|
синхронизаций».
|
||||||
|
|
||||||
|
#### Scenario: Первый refresh после выкатки
|
||||||
|
- **WHEN** проект `AWPCA`, прогонов с `history_at` нет; выдаётся полный `refresh`
|
||||||
|
- **THEN** в выданных параметрах `history = {jql: '(project = AURORA AND labels in ("AWPCA")) AND sprint in
|
||||||
|
openSprints() AND statusCategory != Done'}`; в базе `params` без `history`
|
||||||
|
|
||||||
|
#### Scenario: Регулярный refresh
|
||||||
|
- **WHEN** последний полный прогон с `history_at` начат в `2026-10-22T09:00:00+00:00` по текущей базовой JQL; выдаётся
|
||||||
|
автообновление
|
||||||
|
- **THEN** `history = {since: "2026-10-22T08:45:00Z"}`
|
||||||
|
|
||||||
|
#### Scenario: Журнал не дочитан
|
||||||
|
- **WHEN** прогон в 10:00 завершился `done` с `history.error`, прогон в 09:00 — с принятым журналом; выдаётся следующий
|
||||||
|
полный `refresh`
|
||||||
|
- **THEN** `history.since` — от прогона 09:00
|
||||||
|
|
||||||
|
#### Scenario: Новый проект
|
||||||
|
- **WHEN** прогон с `history_at` в 09:00 по JQL только с `AWPCA`; затем создан проект `STOCKS_DRRK`; выдаётся полный
|
||||||
|
`refresh`
|
||||||
|
- **THEN** `history = {since: "…T08:45:00Z", jql: <стартовая по новой базовой JQL>}`
|
||||||
|
|
||||||
|
#### Scenario: Хвост пакета
|
||||||
|
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, отметка есть
|
||||||
|
- **THEN** `history = {since: …}`; по завершении `done` с `result.history` у его `sync_run` `history_at = NULL`
|
||||||
|
|
||||||
|
#### Scenario: Хвост пакета до первой отметки
|
||||||
|
- **WHEN** выдаётся хвостовой `refresh {keys: [X]}`, прогонов с `history_at` нет
|
||||||
|
- **THEN** в выданных параметрах нет `history`
|
||||||
127
openspec/changes/change-history/specs/data-model/spec.md
Normal file
127
openspec/changes/change-history/specs/data-model/spec.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Схема базы хаба
|
||||||
|
База хаба SHALL быть одним файлом SQLite (WAL) с таблицами `issue`, `issue_link`, `issue_comment`, `issue_change`, `issue_note`,
|
||||||
|
`batch`, `command`, `sync_run`, `agent_status`, `setting`, `project`. Схема SHALL создаваться и мигрироваться самим хабом при
|
||||||
|
старте по номеру версии в `setting.schema_version` (текущая — 5). Миграция существующей базы SHALL выполняться в одной
|
||||||
|
транзакции с блокировкой записи; в той же транзакции, после повторной проверки версии, хаб SHALL скопировать файл базы в
|
||||||
|
`backups/<дата-время>-schema-v<старая>.sqlite` рядом с базой — одна копия на миграцию, сколько бы соединений ни
|
||||||
|
открывалось одновременно. Каталог данных хаба и `backups/` SHALL иметь права `0700` (в базе токены): хаб выставляет их
|
||||||
|
при создании и при каждом старте; если выставить не удалось — предупреждение в лог, работа продолжается. Денежных и
|
||||||
|
дробных полей нет; даты — ISO-8601 строки в UTC, кроме `duedate` и плановых дат (даты без времени), строк времени
|
||||||
|
Jira как есть (`issue_comment.created`/`updated`, `issue_change.created`) и значений полей журнала.
|
||||||
|
|
||||||
|
Миграция v3 → v4 SHALL: создать `project` и колонку `batch.project` (у существующих пакетов `NULL`); разобрать прежнюю
|
||||||
|
настройку `base_jql` вида `… labels = <M>` или `… labels in (<M1>, …)` и завести проекты с этими метками (название проекта
|
||||||
|
`AWPCA` — «АРМ КА», остальных — метка; `board_type = NULL`); отдать прежний `board_id` проекту, если проект один, иначе
|
||||||
|
никому, с предупреждением в лог; у строк `sync_run` с `query`, равной прежней `base_jql`, и у команд `refresh` в
|
||||||
|
`queued`/`claimed` с `params.jql`, равной прежней `base_jql`, записать базовую JQL, вычисленную из проектов. Ключи
|
||||||
|
`base_jql` и `board_id` в `setting` миграция MUST NOT удалять: базу может открыть процесс новой версии раньше, чем
|
||||||
|
перезапущен хаб прежней версии, который их читает. Не разобранная `base_jql` — проектов нет, предупреждение в лог с
|
||||||
|
прежним значением.
|
||||||
|
|
||||||
|
Миграция v4 → v5 SHALL: создать `issue_change` с индексами; добавить колонки `issue_comment.created_at` и
|
||||||
|
`sync_run.history_at`; заполнить `issue_comment.created_at` у имеющихся комментариев приведением `created` к UTC (не
|
||||||
|
разобралось — `NULL`). У имеющихся `sync_run` `history_at = NULL`.
|
||||||
|
|
||||||
|
#### Scenario: Первый запуск
|
||||||
|
- **WHEN** хаб стартует с несуществующим файлом базы
|
||||||
|
- **THEN** файл создаётся, таблицы и индексы на месте, `setting.schema_version` = текущая; каталог данных с правами `0700`
|
||||||
|
|
||||||
|
#### Scenario: Миграция базы v1
|
||||||
|
- **WHEN** хаб стартует с базой `schema_version = 1`, в которой есть задачи, заметки и команда в `claimed`
|
||||||
|
- **THEN** в `backups/` копия базы v1; добавлена `command.deliveries` (у `claimed` — 1, у остальных — 0); добавлена пустая
|
||||||
|
`issue_comment`; данные на месте; `schema_version = 5`
|
||||||
|
|
||||||
|
#### Scenario: Миграция базы v2
|
||||||
|
- **WHEN** хаб стартует с базой `schema_version = 2`, в которой есть задачи, связи и заметки
|
||||||
|
- **THEN** в `backups/` копия базы v2; добавлена пустая `issue_comment`; данные на месте; `schema_version = 5`
|
||||||
|
|
||||||
|
#### Scenario: Миграция базы v3
|
||||||
|
- **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`,
|
||||||
|
в `sync_run` полный прогон с этой `query`
|
||||||
|
- **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql`
|
||||||
|
и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки,
|
||||||
|
пакеты на месте; `schema_version = 5`
|
||||||
|
|
||||||
|
#### Scenario: Миграция базы v4
|
||||||
|
- **WHEN** хаб стартует с базой `schema_version = 4`, у задачи X комментарий с `created = "2026-10-22T13:05:11.000+0300"`,
|
||||||
|
в `sync_run` полный прогон
|
||||||
|
- **THEN** в `backups/` копия базы v4; есть пустая `issue_change`; у комментария `created_at = "2026-10-22T10:05:11+00:00"`,
|
||||||
|
`created` прежний; у прогона `history_at = NULL`; `schema_version = 5`
|
||||||
|
|
||||||
|
#### Scenario: refresh в очереди при миграции
|
||||||
|
- **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}`
|
||||||
|
- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается
|
||||||
|
полным прогоном
|
||||||
|
|
||||||
|
#### Scenario: Два соединения к базе v1
|
||||||
|
- **WHEN** к базе `schema_version = 1` одновременно открываются два соединения
|
||||||
|
- **THEN** миграция выполнена один раз, в `backups/` одна копия `schema-v1`
|
||||||
|
|
||||||
|
#### Scenario: Открытый каталог данных
|
||||||
|
- **WHEN** хаб стартует, каталог данных существует с правами `0755`
|
||||||
|
- **THEN** права каталога и `backups/` — `0700`
|
||||||
|
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Журнал изменений задач
|
||||||
|
Таблица `issue_change` SHALL хранить журнал изменений задач Jira по строке на поле записи журнала: `history_id` (id
|
||||||
|
записи журнала Jira), `seq` (номер поля в записи, с 0), `key`, `at` (время записи в ISO-8601 UTC, приведённое хабом),
|
||||||
|
`created` (время Jira как есть), `author`, `field` (логическое имя), `field_id`, `field_name`, `from_value`,
|
||||||
|
`from_string`, `to_value`, `to_string`, `fetched_at`; PK `(history_id, seq)`, индексы по `at` и по `(key, at)`.
|
||||||
|
Записывать в `issue_change` SHALL только приём журнала от агента (`POST /agent/history`); повторно присланная строка с
|
||||||
|
тем же `(history_id, seq)` MUST NOT менять сохранённую. HTTP API интерфейсов и MCP MUST NOT писать в `issue_change`.
|
||||||
|
|
||||||
|
Логическое имя `field` SHALL вычисляться хабом при приёме: `status`, `description`, `summary`, `assignee`, `priority`,
|
||||||
|
`duedate`, `labels`, `resolution` — если так называется `field_id`, а при его отсутствии — `field_name`; `planned_start`,
|
||||||
|
`planned_end` — если `field_id` равен id этого поля в настройке `field_ids`; `epic_link` — если `field_id` равен
|
||||||
|
`epic_link.field_id`; иначе — `field_name` как есть.
|
||||||
|
|
||||||
|
Время Jira (`created` журнала, `created` комментария) SHALL приводиться к UTC разбором ISO-8601 со смещением вида
|
||||||
|
`+0300`, `+03:00` или `Z`, с дробной частью секунд или без; время без смещения считается UTC; неразбираемое — `NULL` в
|
||||||
|
приведённой колонке (строка журнала с `at = NULL` в ленту не попадает).
|
||||||
|
|
||||||
|
Таблица `issue_comment` SHALL хранить также `created_at` — `created`, приведённое к UTC, заполняемое при каждой записи
|
||||||
|
комментариев.
|
||||||
|
|
||||||
|
#### Scenario: Повтор записи журнала
|
||||||
|
- **WHEN** агент дважды прислал элемент `{history_id: "5001", seq: 0, key: X, field_name: "status", …}`
|
||||||
|
- **THEN** в `issue_change` одна строка `(5001, 0)`; второй приём ничего не изменил
|
||||||
|
|
||||||
|
#### Scenario: Плановая дата по id поля
|
||||||
|
- **WHEN** в настройках `field_ids.planned_end = "customfield_10201"`, агент прислал элемент с `field_id =
|
||||||
|
"customfield_10201"`, `field_name = "Плановое окончание"`
|
||||||
|
- **THEN** у строки `field = "planned_end"`
|
||||||
|
|
||||||
|
#### Scenario: Неизвестное поле
|
||||||
|
- **WHEN** агент прислал элемент с `field_id = null`, `field_name = "Fix Version"`
|
||||||
|
- **THEN** у строки `field = "Fix Version"`
|
||||||
|
|
||||||
|
#### Scenario: Время со смещением
|
||||||
|
- **WHEN** элемент журнала с `created = "2026-10-22T09:00:00.000+0300"`
|
||||||
|
- **THEN** у строки `at = "2026-10-22T06:00:00+00:00"`, `created` — как прислано
|
||||||
|
|
||||||
|
### Requirement: Отметка журнала в журнале синхронизаций
|
||||||
|
Таблица `sync_run` SHALL хранить `history_at` — время, когда хаб принял итог журнала по этому прогону (`NULL` — журнал
|
||||||
|
по прогону не принят). Хаб SHALL проставлять `history_at` при отчёте полного прогона (`refresh` по базовой JQL) со
|
||||||
|
статусом `done` и `result.history` без `error`; у прочих `refresh`, при `failed`, `cancelled`, без `result.history` или с
|
||||||
|
`history.error` — оставлять `NULL`. `history.backfill_error` и непустой `history.incomplete` отметку не держат.
|
||||||
|
|
||||||
|
#### Scenario: Журнал принят
|
||||||
|
- **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 3, changes: 7, incomplete: []}`
|
||||||
|
- **THEN** у его `sync_run` проставлен `history_at`
|
||||||
|
|
||||||
|
#### Scenario: Ошибка стартовой загрузки не держит отметку
|
||||||
|
- **WHEN** `refresh` по базовой JQL завершился `done` с `result.history = {issues: 1, changes: 2, incomplete: [],
|
||||||
|
backfill_error: "HTTP 400 …"}`
|
||||||
|
- **THEN** у его `sync_run` проставлен `history_at`
|
||||||
|
|
||||||
|
#### Scenario: Ошибка журнала
|
||||||
|
- **WHEN** `refresh` завершился `done` с `result.history = {error: "HTTP 500 …"}`
|
||||||
|
- **THEN** у его `sync_run` `history_at = NULL`
|
||||||
|
|
||||||
|
#### Scenario: Старый агент
|
||||||
|
- **WHEN** агент v12 завершил `refresh` `done` без `result.history`
|
||||||
|
- **THEN** у его `sync_run` `history_at = NULL`
|
||||||
80
openspec/changes/change-history/specs/hub-api/spec.md
Normal file
80
openspec/changes/change-history/specs/hub-api/spec.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приём журнала изменений
|
||||||
|
Хаб SHALL принимать от агента журнал изменений задач маршрутом `POST /agent/history` (агентский токен) с телом
|
||||||
|
`{command_id, changes: [...]}`, элемент — `{history_id, seq, key, created, author, field_id, field_name, from,
|
||||||
|
from_string, to, to_string}`. До записи хаб SHALL проверить форму всей пачки: `changes` — список объектов;
|
||||||
|
`history_id`, `key`, `created`, `field_name` — непустые строки; `seq` — целое ≥ 0; `author`, `field_id`, `from`,
|
||||||
|
`from_string`, `to`, `to_string` — строка или `null`. Нарушение — 422 `bad_history` с номером элемента и именем поля,
|
||||||
|
пачка не применяется. Принятая пачка записывается в `issue_change` в одной транзакции (см. `data-model`, «Журнал
|
||||||
|
изменений задач»); ответ — `{accepted, duplicates}` (записано новых строк, отброшено повторов). Задача элемента может
|
||||||
|
отсутствовать в зеркале — элемент всё равно записывается.
|
||||||
|
|
||||||
|
#### Scenario: Пачка журнала
|
||||||
|
- **WHEN** `POST /agent/history` с тремя элементами, один из них уже есть в `issue_change`
|
||||||
|
- **THEN** 200 `{accepted: 2, duplicates: 1}`
|
||||||
|
|
||||||
|
#### Scenario: Неверный элемент
|
||||||
|
- **WHEN** `POST /agent/history` с двумя элементами, у второго `seq = "0"`
|
||||||
|
- **THEN** 422 `bad_history` с номером элемента 1 и полем `seq`; ни один элемент не записан
|
||||||
|
|
||||||
|
#### Scenario: Клиентский токен
|
||||||
|
- **WHEN** `POST /agent/history` клиентским токеном
|
||||||
|
- **THEN** 401
|
||||||
|
|
||||||
|
### Requirement: Лента изменений
|
||||||
|
Хаб SHALL отдавать интерфейсам (клиентский токен) ленту событий за период маршрутом `GET /changes` с параметрами:
|
||||||
|
- `since`, `until` — ISO-8601 со смещением или `Z` (время без смещения — 422); без `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`), вторым — смена статуса
|
||||||
46
openspec/changes/change-history/specs/mcp/spec.md
Normal file
46
openspec/changes/change-history/specs/mcp/spec.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Лента изменений в MCP
|
||||||
|
Инструмент `changes` SHALL отдавать ленту изменений (`GET /changes`) с параметрами `hours` (число > 0), `since`,
|
||||||
|
`until`, `kinds`, `key`. `hours` и `since` вместе — ответ «либо hours, либо since». Без `hours` и `since` — с начала
|
||||||
|
текущих суток по местному времени машины MCP-сервера; с `hours` — `since = сейчас − hours`. `since`/`until` без
|
||||||
|
смещения считаются местным временем машины MCP-сервера (хаб время без смещения отвергает). В сессии, привязанной к
|
||||||
|
проекту 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»
|
||||||
@ -9,12 +9,13 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
from datetime import timedelta
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from urllib.parse import parse_qs, unquote, urlparse
|
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 .commands import CommandError
|
||||||
from .paths import backups_dir as default_backups_dir
|
from .paths import backups_dir as default_backups_dir
|
||||||
from .paths import secure_dir
|
from .paths import secure_dir
|
||||||
@ -210,6 +211,15 @@ def h_agent_state(hub, conn, m, q, body):
|
|||||||
return 200, out
|
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):
|
def h_known_keys(hub, conn, m, q, body):
|
||||||
return 200, {"keys": mirror.known_keys(conn)}
|
return 200, {"keys": mirror.known_keys(conn)}
|
||||||
|
|
||||||
@ -241,6 +251,38 @@ def h_issue(hub, conn, m, q, body):
|
|||||||
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
|
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) if _OFFSET.search(raw) else None # без смещения — не угадываем пояс (M-2)
|
||||||
|
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):
|
def h_board(hub, conn, m, q, body):
|
||||||
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q))
|
return 200, mirror.board(conn, projects.base_jql(conn), _project_param(conn, q))
|
||||||
|
|
||||||
@ -449,12 +491,14 @@ ROUTES = [
|
|||||||
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
|
("GET", r"^/agent/commands/(\d+)$", h_agent_command, "agent"),
|
||||||
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
|
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
|
||||||
("POST", r"^/agent/state$", h_agent_state, "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/known-keys$", h_known_keys, "agent"),
|
||||||
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
("GET", r"^/agent/release$", h_agent_release, "agent"),
|
||||||
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
|
||||||
("GET", r"^/issues$", h_issues, "client"),
|
("GET", r"^/issues$", h_issues, "client"),
|
||||||
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
|
||||||
("GET", r"^/board$", h_board, "client"),
|
("GET", r"^/board$", h_board, "client"),
|
||||||
|
("GET", r"^/changes$", h_changes, "client"),
|
||||||
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
|
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
|
||||||
("POST", r"^/batches$", h_batches_post, "client"),
|
("POST", r"^/batches$", h_batches_post, "client"),
|
||||||
("GET", r"^/batches$", h_batches_get, "client"),
|
("GET", r"^/batches$", h_batches_get, "client"),
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from . import backup, paths
|
from . import backup, paths
|
||||||
|
|
||||||
SCHEMA_VERSION = 4
|
SCHEMA_VERSION = 5
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS setting (
|
CREATE TABLE IF NOT EXISTS setting (
|
||||||
@ -50,8 +50,28 @@ CREATE TABLE IF NOT EXISTS issue_comment (
|
|||||||
created TEXT,
|
created TEXT,
|
||||||
updated TEXT,
|
updated TEXT,
|
||||||
body TEXT NOT NULL DEFAULT '',
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT,
|
||||||
PRIMARY KEY (key, comment_id)
|
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 (
|
CREATE TABLE IF NOT EXISTS issue_note (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
fact_status TEXT,
|
fact_status TEXT,
|
||||||
@ -99,7 +119,8 @@ CREATE TABLE IF NOT EXISTS sync_run (
|
|||||||
links_count INTEGER NOT NULL DEFAULT 0,
|
links_count INTEGER NOT NULL DEFAULT 0,
|
||||||
jira_calls INTEGER NOT NULL DEFAULT 0,
|
jira_calls INTEGER NOT NULL DEFAULT 0,
|
||||||
rate_limited 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 (
|
CREATE TABLE IF NOT EXISTS project (
|
||||||
label TEXT PRIMARY KEY,
|
label TEXT PRIMARY KEY,
|
||||||
@ -123,12 +144,28 @@ def _migrate_projects(conn) -> None:
|
|||||||
projects.migrate_v3(conn)
|
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)
|
# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
|
||||||
MIGRATIONS = {
|
MIGRATIONS = {
|
||||||
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
|
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
|
||||||
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
|
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
|
||||||
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||||
3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки
|
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")
|
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)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
return datetime.now(timezone.utc).replace(microsecond=0)
|
return datetime.now(timezone.utc).replace(microsecond=0)
|
||||||
|
|
||||||
|
|||||||
169
src/jira_hub/history.py
Normal file
169
src/jira_hub/history.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""Журнал изменений задач (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")
|
||||||
|
_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, new = 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))
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# --- лента и история задачи (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]
|
||||||
@ -7,6 +7,7 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import stat
|
import stat
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from mcp.server.fastmcp import FastMCP
|
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 [])
|
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
|
||||||
else:
|
else:
|
||||||
cmts = ["комментариев нет"]
|
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
|
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
|
||||||
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
|
+ ["команды:"] + (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:
|
def fmt_batch(b) -> str:
|
||||||
est = round((b.get("remaining_seconds") or 0) / 60)
|
est = round((b.get("remaining_seconds") or 0) / 60)
|
||||||
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
|
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
|
||||||
@ -204,6 +261,40 @@ def issue(key: str) -> str:
|
|||||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
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 — через запятую из
|
||||||
|
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=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
|
||||||
|
project=None if key else PROJECT)
|
||||||
|
return fmt_changes(data, now)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
@scoped(True)
|
@scoped(True)
|
||||||
@ -379,9 +470,13 @@ def agent_status() -> str:
|
|||||||
def sync_log(limit: int = 10) -> str:
|
def sync_log(limit: int = 10) -> str:
|
||||||
"""Журнал обновлений зеркала."""
|
"""Журнал обновлений зеркала."""
|
||||||
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
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'))}, "
|
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']}"
|
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, "
|
||||||
for r in runs) or "обновлений не было"
|
f"{journal(r)}" for r in runs) or "обновлений не было"
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
|
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from . import db
|
from . import db, history
|
||||||
|
|
||||||
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
|
||||||
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
|
||||||
@ -58,10 +58,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
|
|||||||
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
|
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
|
||||||
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
|
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) "
|
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "")
|
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "",
|
||||||
for c in it["comments"]])
|
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])
|
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
|
||||||
for link in links:
|
for link in links:
|
||||||
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
|
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
|
||||||
@ -154,6 +154,7 @@ def get_issue(conn, key) -> dict | None:
|
|||||||
d["comments"] = [dict(r) for r in conn.execute(
|
d["comments"] = [dict(r) for r in conn.execute(
|
||||||
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
|
"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,))]
|
"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["note"] = get_note(conn, key)
|
||||||
d["commands"] = [dict(r) for r in conn.execute(
|
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",
|
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
|
||||||
from . import commands as C
|
from . import commands as C
|
||||||
from . import db, files, mirror, projects, settings
|
from . import db, files, history, mirror, projects, settings
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -264,10 +264,14 @@ def _redeliver(conn, now) -> dict | None:
|
|||||||
|
|
||||||
def deliver_params(conn, cmd, files_dir) -> dict:
|
def deliver_params(conn, cmd, files_dir) -> dict:
|
||||||
"""Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None,
|
"""Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None,
|
||||||
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них:
|
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала
|
||||||
повтор команды проходит проверку как новая."""
|
(change-history). В базе params остаются без них: повтор команды проходит проверку как новая."""
|
||||||
if cmd["type"] == "refresh":
|
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":
|
if cmd["type"] != "ensure_attachment":
|
||||||
return cmd["params"]
|
return cmd["params"]
|
||||||
size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None
|
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))
|
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
|
||||||
if isinstance(result, dict) and "boards" in result:
|
if isinstance(result, dict) and "boards" in result:
|
||||||
projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
|
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):
|
if status == "done" and is_full_refresh(conn, command_id):
|
||||||
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
|
||||||
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)
|
||||||
|
|||||||
@ -941,3 +941,67 @@ def test_board_type_round_trip(hub): # сце
|
|||||||
with pytest.raises(HubError) as e:
|
with pytest.raises(HubError) as e:
|
||||||
c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]})
|
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
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
({"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:
|
||||||
|
hub["client"].get("/changes", **params)
|
||||||
|
assert e.value.status == status
|
||||||
|
if name:
|
||||||
|
assert e.value.error == "bad_request" and name in e.value.detail
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import sqlite3
|
|||||||
import stat
|
import stat
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from jira_hub import backup, db, paths, projects, queue, settings
|
from jira_hub import backup, db, paths, projects, queue, settings
|
||||||
|
|
||||||
|
|
||||||
@ -12,14 +14,23 @@ def test_connect_creates_schema(open_db):
|
|||||||
conn = open_db()
|
conn = open_db()
|
||||||
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
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",
|
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 "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
|
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)")}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
|
||||||
assert "deliveries" in cols
|
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):
|
def _v1_database(path):
|
||||||
"""База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными."""
|
"""База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными."""
|
||||||
conn = db.connect(path)
|
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', '{}', ?, ?)",
|
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))
|
(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', 'В работе')")
|
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 command DROP COLUMN deliveries")
|
||||||
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||||
conn.execute("DROP TABLE project")
|
conn.execute("DROP TABLE project")
|
||||||
@ -39,7 +51,7 @@ def test_migrates_v1_with_backup(tmp_path):
|
|||||||
path = tmp_path / "hub.sqlite"
|
path = tmp_path / "hub.sqlite"
|
||||||
_v1_database(path)
|
_v1_database(path)
|
||||||
conn = db.connect(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()
|
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 [(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"] == "В работе"
|
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 errors == []
|
||||||
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
|
||||||
conn = db.connect(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"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@ -196,13 +208,14 @@ def test_migrates_v2_with_backup(tmp_path):
|
|||||||
conn = db.connect(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(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', 'В работе')")
|
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||||
|
_drop_v5(conn)
|
||||||
conn.execute("DROP TABLE issue_comment")
|
conn.execute("DROP TABLE issue_comment")
|
||||||
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||||
conn.execute("DROP TABLE project")
|
conn.execute("DROP TABLE project")
|
||||||
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
||||||
conn.close()
|
conn.close()
|
||||||
conn = db.connect(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"
|
||||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
|
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 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"] == "В работе"
|
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))
|
(ts, ts, base_jql, cid))
|
||||||
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,))
|
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', 'В работе')")
|
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("ALTER TABLE batch DROP COLUMN project")
|
||||||
conn.execute("DROP TABLE project")
|
conn.execute("DROP TABLE project")
|
||||||
conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'")
|
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'")]
|
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 sorted(queued) == sorted([new, "key = A-1"])
|
||||||
assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None
|
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
|
assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@ -288,5 +302,44 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
|
|||||||
conn = db.connect(tmp_path / "hub.sqlite")
|
conn = db.connect(tmp_path / "hub.sqlite")
|
||||||
assert projects.list_projects(conn) == []
|
assert projects.list_projects(conn) == []
|
||||||
assert "ORDER BY key" in caplog.text
|
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),
|
||||||
|
("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
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
conn.close()
|
||||||
|
|||||||
175
tests/test_history.py
Normal file
175
tests/test_history.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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}])
|
||||||
|
|
||||||
|
|
||||||
|
# --- лента и история задачи (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") == []
|
||||||
@ -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,
|
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}]
|
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
|
||||||
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
|
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():
|
def test_async_tools_are_coroutines_and_registered():
|
||||||
@ -492,3 +492,128 @@ def test_run_binds_project(monkeypatch):
|
|||||||
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None)
|
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None)
|
||||||
mcp_server.run("AWPCA")
|
mcp_server.run("AWPCA")
|
||||||
assert mcp_server.PROJECT == "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_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] == "за период изменений нет"
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
|||||||
@ -311,3 +311,11 @@ def test_labels_of(conn):
|
|||||||
_scene(conn)
|
_scene(conn)
|
||||||
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
|
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
|
||||||
assert mirror.labels_of(conn, "AURORA-404") is None
|
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}
|
||||||
|
|||||||
@ -941,3 +941,90 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
|
|||||||
c = queue.claim(conn)
|
c = queue.claim(conn)
|
||||||
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
|
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
|
||||||
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
|
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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user