Compare commits

...

9 Commits

Author SHA1 Message Date
Andrey Epifancev
479bb6625f Решения исполнения в design.md (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:41 +04:00
Andrey Epifancev
c6a5ef15b3 Лента: крайние даты не роняют хаб, время без смещения — 422 в API и местное в MCP (#70, M-1, M-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:50:26 +04:00
Andrey Epifancev
a27ebc9586 MCP: лента изменений, история в issue, журнал в sync_log (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:23:39 +04:00
Andrey Epifancev
337e36f1a0 Лента изменений GET /changes и история задачи (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:15:23 +04:00
Andrey Epifancev
e3649a1a38 Отметка журнала: history в refresh, sync_run.history_at (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:12:16 +04:00
Andrey Epifancev
f0590ac9ea Приём журнала изменений: POST /agent/history (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:08:29 +04:00
Andrey Epifancev
dc9419fbb9 Схема v5: issue_change, время комментариев в UTC (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:05:53 +04:00
Andrey Epifancev
ba7ac9fb41 План: журнал изменений задач, хаб (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:04:52 +04:00
Andrey Epifancev
5a6cdecc8e OpenSpec change-history: журнал изменений задач (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:04:52 +04:00
23 changed files with 1864 additions and 28 deletions

View File

@ -7,10 +7,11 @@
| Модуль | Ответственность | Зависит от |
|---|---|---|
| `paths` | каталог данных (`JIRA_HUB_HOME`), пути базы и `backups/`, права 0700 (`secure_dir`) | stdlib |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect` | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) |
| `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) |
| `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` |
| `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` |
| `mirror` | зеркало задач, связей и комментариев, разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board` | `db` |
| `mirror` | зеркало задач, связей и комментариев (время комментария в UTC — `created_at`), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` |
| `history` | журнал изменений задач `issue_change` (схема v5): проверка и запись пачек агента (`check_changes`, `record_changes`, логическое имя поля `logical_field`), параметр `history` для выдачи `refresh` (`delivery_param`: отметка — последний полный прогон с `sync_run.history_at`, минус 15 минут; стартовая JQL `backfill_jql`), признак принятого журнала `accepted`, лента `feed` (журнал + новые комментарии по `issue_comment.created_at`) и история задачи `issue_changes` | `db`, `settings`, `projects` |
| `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` |
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
@ -19,7 +20,7 @@
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
@ -38,22 +39,27 @@
боковой панели, отслеживание пакета) — `web/src/shell/refresh.ts` (провайдер в `App.tsx`); доска отдаёт наружу только
источник и события снимка.
Цепочки: `api → queue → commands/mirror/settings/projects → db`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
Цепочки: `api → queue → commands/mirror/settings/projects/history → db`; `mirror → history → projects/settings`; `db → projects` (только миграция, ленивый импорт); `projects → settings`; `db → backup → paths`, `db → paths`; `files → paths`; `api → relay → settings`; `mcp_server → client → HTTP API`; `importer → mirror/settings`.
## Потоки данных
- Запись в Jira: клиент (`mcp_server`/интерфейс) → `POST /batches` → `queue` → агент `claim` → Jira → `POST /agent/commands/{id}/result`.
- Вложение: файл → `POST /files` → `files/<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`.
- Чтение Jira: команда `refresh` → агент → `POST /agent/state` → `mirror.apply_state`.
- Журнал изменений: выдача `refresh` (`queue.deliver_params` дописывает `history` — `since` и/или стартовую `jql`) →
агент v13 после состояния читает changelog Jira → `POST /agent/history` → `history.record_changes` (повторы
отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона
(новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `jira-hub agent publish` → `release/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
- Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`.
## Границы
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
- Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают.
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects` напрямую и ходит только через `client`; из `files` берёт только предел
- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history` напрямую и ходит только через `client`; из `files` берёт только предел
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит.

View File

@ -9,7 +9,7 @@
- Хаб не знает пароль Jira и не ходит в Jira: учётные данные и сеть до Jira только у агента.
- Зеркало задач пишет только `mirror.apply_state` по загрузке состояния от агента (`POST /agent/state`); правки идут командами через очередь.
- Хаб не создаёт команды сам: повтор после failed — новая команда по запросу человека/интерфейса. Команду, выданную агенту и оставшуюся без отчёта, хаб довыдаёт при следующем claim (до 3 выдач, затем failed).
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite`, `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Данные: `~/.local/share/jira-hub/` (`JIRA_HUB_HOME`): `hub.sqlite` (зеркало, очередь, журнал изменений `issue_change`), `backups/`, `release/` (опубликованная версия агента), `files/` (файлы вложений по sha256, уборка через 30 дней); каталог данных, `backups/`, `release/` и `files/` — права 0700. Порт по умолчанию 8790.
- Два статических токена (в таблице `setting`): агентский открывает только `/agent/*`, клиентский — остальное.
## Архитектура

View 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)`

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View 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 — интерфейсов, которые так шлют, нет.

View 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) или сбой чтения журнала ломает обновление зеркала.

View 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`

View 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`

View 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`

View 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`), вторым — смена статуса

View 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»

View File

@ -9,12 +9,13 @@ import re
import threading
import time
import traceback
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qs, unquote, urlparse
from . import backup, db, files, mirror, projects, queue, relay, release, settings
from . import backup, db, files, history, mirror, projects, queue, relay, release, settings
from .commands import CommandError
from .paths import backups_dir as default_backups_dir
from .paths import secure_dir
@ -210,6 +211,15 @@ def h_agent_state(hub, conn, m, q, body):
return 200, out
def h_agent_history(hub, conn, m, q, body):
changes = body.get("changes")
try:
history.check_changes(changes) # до записи: неверная пачка не применяется целиком
except ValueError as e:
raise ApiError(422, "bad_history", str(e))
return 200, history.record_changes(conn, changes)
def h_known_keys(hub, conn, m, q, body):
return 200, {"keys": mirror.known_keys(conn)}
@ -241,6 +251,38 @@ def h_issue(hub, conn, m, q, body):
return 200, _not_found(mirror.get_issue(conn, m.group(1)), "issue")
_OFFSET = re.compile(r"(Z|[+-]\d{2}:?\d{2})$")
def _time_param(q, name, default):
raw = q.get(name)
if raw in (None, ""):
return default
value = db.jira_utc(raw) 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):
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"),
("POST", r"^/agent/commands/(\d+)/result$", h_agent_result, "agent"),
("POST", r"^/agent/state$", h_agent_state, "agent"),
("POST", r"^/agent/history$", h_agent_history, "agent"),
("GET", r"^/agent/known-keys$", h_known_keys, "agent"),
("GET", r"^/agent/release$", h_agent_release, "agent"),
("GET", r"^/agent/files/([^/]+)$", h_agent_file, "agent"),
("GET", r"^/issues$", h_issues, "client"),
("GET", r"^/issues/([^/]+)$", h_issue, "client"),
("GET", r"^/board$", h_board, "client"),
("GET", r"^/changes$", h_changes, "client"),
("PUT", r"^/issues/([^/]+)/note$", h_note_put, "client"),
("POST", r"^/batches$", h_batches_post, "client"),
("GET", r"^/batches$", h_batches_get, "client"),

View File

@ -8,7 +8,7 @@ from pathlib import Path
from . import backup, paths
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
@ -50,8 +50,28 @@ CREATE TABLE IF NOT EXISTS issue_comment (
created TEXT,
updated TEXT,
body TEXT NOT NULL DEFAULT '',
created_at TEXT,
PRIMARY KEY (key, comment_id)
);
CREATE TABLE IF NOT EXISTS issue_change (
history_id TEXT NOT NULL,
seq INTEGER NOT NULL,
key TEXT NOT NULL,
at TEXT,
created TEXT NOT NULL,
author TEXT,
field TEXT NOT NULL,
field_id TEXT,
field_name TEXT NOT NULL,
from_value TEXT,
from_string TEXT,
to_value TEXT,
to_string TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (history_id, seq)
);
CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at);
CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
@ -99,7 +119,8 @@ CREATE TABLE IF NOT EXISTS sync_run (
links_count INTEGER NOT NULL DEFAULT 0,
jira_calls INTEGER NOT NULL DEFAULT 0,
rate_limited INTEGER NOT NULL DEFAULT 0,
command_id INTEGER
command_id INTEGER,
history_at TEXT
);
CREATE TABLE IF NOT EXISTS project (
label TEXT PRIMARY KEY,
@ -123,12 +144,28 @@ def _migrate_projects(conn) -> None:
projects.migrate_v3(conn)
def _add_column(table: str, column: str):
"""Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой."""
def step(conn) -> None:
if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT")
return step
def _fill_comment_utc(conn) -> None:
for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall():
conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?",
(jira_utc(r["created"]), r["key"], r["comment_id"]))
# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
MIGRATIONS = {
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки
4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений
_fill_comment_utc],
}
@ -217,6 +254,22 @@ def transaction(conn: sqlite3.Connection):
conn.execute("COMMIT")
def jira_utc(value) -> str | None:
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате
now(); без смещения — UTC; неразбираемое — None."""
if not isinstance(value, str) or not value:
return None
s = value[:-1] + "+00:00" if value.endswith("Z") else value
if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM
s = s[:-2] + ":" + s[-2:]
try:
d = datetime.fromisoformat(s)
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:
return datetime.now(timezone.utc).replace(microsecond=0)

169
src/jira_hub/history.py Normal file
View 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]

View File

@ -7,6 +7,7 @@ import inspect
import json
import stat
import time
from datetime import datetime, timedelta
from pathlib import Path
from mcp.server.fastmcp import FastMCP
@ -141,10 +142,66 @@ def fmt_issue(d) -> str:
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
else:
cmts = ["комментариев нет"]
changes = d.get("changes") or []
if changes:
cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \
([f" ещё {len(changes) - 5}"] if len(changes) > 5 else [])
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
DATE_LABELS = {"duedate": "срок", "planned_start": "плановое начало", "planned_end": "плановое окончание"}
def _day(value, text) -> str:
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
return f"{value[8:10]}.{value[5:7]}"
return _v(text)
def fmt_event(e: dict, today) -> str:
"""Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today
(today = None — дата всегда)."""
if e.get("at"):
local = datetime.fromisoformat(e["at"]).astimezone()
when = local.strftime("%H:%M") if today is not None and local.date() == today else local.strftime("%d.%m %H:%M")
else:
when = "—"
kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
if kind == "comment":
what = "комментарий: " + _cut(to, 200)
elif kind == "status":
what = f"статус «{_v(frm)}» → «{_v(to)}»"
elif kind == "description":
what = "описание изменено"
elif kind == "dates":
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
f"{_day(e.get('to_value'), to)}")
else:
what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»"
return f"{when} {_v(e.get('author'))}: {what}"
def fmt_changes(data: dict, now: datetime) -> str:
"""Лента по задачам: задачи — по последнему событию (лента уже новые сверху), под задачей — её события."""
def span(at):
return datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M")
lines = [f"изменения с {span(data['since'])} по {span(data['until'])}"]
events = data.get("changes") or []
if not events:
return "\n".join(lines + ["за период изменений нет"])
groups: dict[str, list] = {}
for e in events:
groups.setdefault(e["key"], []).append(e)
for key, items in groups.items():
lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}")
lines += [" " + fmt_event(e, now.date()) for e in items]
if data.get("truncated"):
lines.append(f"показаны последние {len(events)} событий, сузьте период")
return "\n".join(lines)
def fmt_batch(b) -> str:
est = round((b.get("remaining_seconds") or 0) / 60)
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
@ -204,6 +261,40 @@ def issue(key: str) -> str:
return fmt_issue(_client().get(f"/issues/{key}"))
def _local_if_naive(value: str) -> str:
"""Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть."""
if not value:
return value
try:
d = datetime.fromisoformat(value)
except ValueError:
return value
return d.astimezone().isoformat() if d.tzinfo is None else value
@mcp.tool()
@guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из
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()
@guard
@scoped(True)
@ -379,9 +470,13 @@ def agent_status() -> str:
def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала."""
runs = _client().get("/sync-runs", limit=limit)["runs"]
def journal(r):
at = r.get("history_at")
return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет"
return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, "
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}"
for r in runs) or "обновлений не было"
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, "
f"{journal(r)}" for r in runs) or "обновлений не было"
@mcp.tool()

View File

@ -2,7 +2,7 @@
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json
from . import db
from . import db, history
ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "priority", "assignee", "reporter",
"duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
@ -58,10 +58,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit
if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем
conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],))
conn.executemany(
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) "
"VALUES (?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "")
for c in it["comments"]])
"INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
[(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "",
db.jira_utc(c.get("created"))) for c in it["comments"]])
conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)",
@ -154,6 +154,7 @@ def get_issue(conn, key) -> dict | None:
d["comments"] = [dict(r) for r in conn.execute(
"SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? "
"ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))]
d["changes"] = history.issue_changes(conn, key)
d["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute(
"SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",

View File

@ -7,7 +7,7 @@ from datetime import timedelta
from . import backup as _backup # noqa: F401 (имя модуля занято параметром backup в claim)
from . import commands as C
from . import db, files, mirror, projects, settings
from . import db, files, history, mirror, projects, settings
log = logging.getLogger(__name__)
@ -264,10 +264,14 @@ def _redeliver(conn, now) -> dict | None:
def deliver_params(conn, cmd, files_dir) -> dict:
"""Параметры команды для агента: ensure_attachment получает size — текущий размер файла в хранилище или None,
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7). В базе params остаются без них:
повтор команды проходит проверку как новая."""
если файла уже нет (решение 5); refresh — boards, доски проектов (design #7), и history — отметка журнала
(change-history). В базе params остаются без них: повтор команды проходит проверку как новая."""
if cmd["type"] == "refresh":
return {**cmd["params"], "boards": projects.boards(conn)}
out = {**cmd["params"], "boards": projects.boards(conn)}
hist = history.delivery_param(conn, cmd["params"]) # отметка журнала (change-history, design #5)
if hist is not None:
out["history"] = hist
return out
if cmd["type"] != "ensure_attachment":
return cmd["params"]
size = files.size(files_dir, cmd["params"].get("file")) if files_dir is not None else None
@ -370,6 +374,8 @@ def report_result(conn, command_id, status, result=None, error=None, jira_calls=
(now, int(jira_calls or 0), int(rate_limited or 0), command_id))
if isinstance(result, dict) and "boards" in result:
projects.record_board_types(conn, result["boards"]) # в любом статусе; без ключа (агент v11) типы не трогаем
if status == "done" and is_full_refresh(conn, command_id) and history.accepted(result):
conn.execute("UPDATE sync_run SET history_at = ? WHERE command_id = ?", (now, command_id))
if status == "done" and is_full_refresh(conn, command_id):
# полный прогон не обновил задачу — в базовую JQL она больше не входит: сосед. Пустой прогон (0 задач) —
# почти всегда сбой выборки, а не «все задачи ушли»: соседей не трогаем (M-12)

View File

@ -941,3 +941,67 @@ def test_board_type_round_trip(hub): # сце
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "s", "commands": [{"type": "ensure_sprint", "params": {"key": "A-5", "sprint": "С2"}}]})
assert e.value.status == 422 and "канбан-доска" in e.value.detail
HISTORY_ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров",
"field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001",
"to_string": "Готово"}
def test_agent_history_route(hub):
assert hub["agent"].post("/agent/history", {"command_id": None, "changes": [HISTORY_ITEM]}) == {"accepted": 1, "duplicates": 0}
items = [HISTORY_ITEM, {**HISTORY_ITEM, "seq": 1}, {**HISTORY_ITEM, "seq": 2}]
assert hub["agent"].post("/agent/history", {"changes": items}) == {"accepted": 2, "duplicates": 1}
with pytest.raises(HubError) as e:
hub["client"].post("/agent/history", {"changes": items})
assert e.value.status == 401
with pytest.raises(HubError) as e:
hub["agent"].post("/agent/history", {"changes": [{**HISTORY_ITEM, "seq": 7}, {**HISTORY_ITEM, "seq": "0"}]})
assert e.value.status == 422 and e.value.error == "bad_history" and "changes[1].seq" in e.value.detail
conn = hub["hub"].conn()
try:
assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 3
finally:
conn.close()
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

View File

@ -5,6 +5,8 @@ import sqlite3
import stat
import threading
import pytest
from jira_hub import backup, db, paths, projects, queue, settings
@ -12,14 +14,23 @@ def test_connect_creates_schema(open_db):
conn = open_db()
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
"agent_status", "project"} <= names
"agent_status", "project", "issue_change"} <= names
assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")}
assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")}
assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")}
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
assert "deliveries" in cols
def _drop_v5(conn):
"""Снять с базы текущей схемы то, что добавила v5: так строятся базы прежних версий."""
conn.execute("DROP TABLE issue_change")
conn.execute("ALTER TABLE issue_comment DROP COLUMN created_at")
conn.execute("ALTER TABLE sync_run DROP COLUMN history_at")
def _v1_database(path):
"""База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными."""
conn = db.connect(path)
@ -28,6 +39,7 @@ def _v1_database(path):
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)",
(status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None))
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_drop_v5(conn)
conn.execute("ALTER TABLE command DROP COLUMN deliveries")
conn.execute("ALTER TABLE batch DROP COLUMN project")
conn.execute("DROP TABLE project")
@ -39,7 +51,7 @@ def test_migrates_v1_with_backup(tmp_path):
path = tmp_path / "hub.sqlite"
_v1_database(path)
conn = db.connect(path)
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall()
assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)]
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
@ -186,7 +198,7 @@ def test_parallel_connect_makes_one_backup(tmp_path):
assert errors == []
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
conn = db.connect(path)
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
conn.close()
@ -196,13 +208,14 @@ def test_migrates_v2_with_backup(tmp_path):
conn = db.connect(path)
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_drop_v5(conn)
conn.execute("DROP TABLE issue_comment")
conn.execute("ALTER TABLE batch DROP COLUMN project")
conn.execute("DROP TABLE project")
conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
conn.close()
conn = db.connect(path)
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т"
assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
@ -247,6 +260,7 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=
(ts, ts, base_jql, cid))
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,))
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_drop_v5(conn)
conn.execute("ALTER TABLE batch DROP COLUMN project")
conn.execute("DROP TABLE project")
conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'")
@ -269,7 +283,7 @@ def test_migrates_v3_creates_awpca(tmp_path):
queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")]
assert sorted(queued) == sorted([new, "key = A-1"])
assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
conn.close()
@ -288,5 +302,44 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
conn = db.connect(tmp_path / "hub.sqlite")
assert projects.list_projects(conn) == []
assert "ORDER BY key" in caplog.text
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5"
conn.close()
@pytest.mark.parametrize("raw, want", [
("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"),
("2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11+00:00"),
("2026-10-22T10:05:11Z", "2026-10-22T10:05:11+00:00"),
("2026-10-22T10:05:11.123Z", "2026-10-22T10:05:11+00:00"),
("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"),
("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"),
("вчера", None), ("", None), (None, None), (42, None),
("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()

175
tests/test_history.py Normal file
View 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") == []

View File

@ -159,7 +159,7 @@ def test_sync_log_content(monkeypatch):
runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None,
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1"
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет"
def test_async_tools_are_coroutines_and_registered():
@ -492,3 +492,128 @@ def test_run_binds_project(monkeypatch):
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None)
mcp_server.run("AWPCA")
assert mcp_server.PROJECT == "AWPCA"
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
from datetime import datetime, timedelta, timezone # noqa: E402
def _local(at: str) -> datetime:
return datetime.fromisoformat(at).astimezone()
def _ev(**kw):
base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status",
"field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None,
"to_value": None}
return {**base, **kw}
def test_fmt_event_status():
e = _ev()
hm = _local(e["at"]).strftime("%H:%M")
assert mcp_server.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»"
def test_fmt_event_other_day_has_date():
e = _ev()
d = _local(e["at"])
assert mcp_server.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M "))
assert mcp_server.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M "))
def test_fmt_event_dates_by_value():
e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None)
assert mcp_server.fmt_event(e, None).endswith(": срок 25.10 → —")
e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0")
assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
def test_fmt_event_kinds():
assert mcp_server.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено")
long = "к" * 250
out = mcp_server.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None)
assert out.endswith(": комментарий: " + "к" * 199 + "…")
out = mcp_server.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None)
assert out.endswith(": assignee: «—» → «ivanov»")
def test_fmt_changes_grouped():
now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone()
data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [
_ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"),
_ev(at="2026-10-22T08:15:00+00:00"),
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description",
field="description")]}
lines = mcp_server.fmt_changes(data, now).split("\n")
assert lines[0].startswith("изменения с ")
assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
assert lines[4] == "X Тема — Готово"
assert lines[-1] == "показаны последние 3 событий, сузьте период"
def test_fmt_changes_empty():
out = mcp_server.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00",
"truncated": False, "changes": []}, datetime.now().astimezone())
assert out.split("\n")[1] == "за период изменений нет"
def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw):
items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров",
"field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None,
"to_string": f"s{i}", **kw} for i in range(n)]
live["agent"].post("/agent/history", {"changes": items})
def test_changes_today(live): # «Что закрылось сегодня» / «За два часа»
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово",
"labels": ["AWPCA"]}], "links": []})
now = datetime.now(timezone.utc).replace(microsecond=0)
_history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat())
_history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте
out = mcp_server.changes(kinds="status")
assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out
assert "A-1" in mcp_server.changes(hours=2)
assert "B-1" in mcp_server.changes(key="B-1")
def test_changes_hours_and_since(live):
assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
assert mcp_server.changes(hours=0) == "hours — число больше 0"
def test_changes_bad_since(live):
assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422")
def test_changes_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())

View File

@ -311,3 +311,11 @@ def test_labels_of(conn):
_scene(conn)
assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"]
assert mirror.labels_of(conn, "AURORA-404") is None
def test_apply_state_comment_created_at(conn):
mirror.apply_state(conn, [{"key": "X", "comments": [
{"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"},
{"id": "2", "body": "b", "created": "мусор"}]}], [])
rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")}
assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None}

View File

@ -941,3 +941,90 @@ def test_refresh_failed_result_records_board_types(conn, add_project): # R-3
c = queue.claim(conn)
queue.report_result(conn, c["id"], "failed", result={"boards": {"5120": "kanban"}}, error="e")
assert projects.get(conn, "STOCKS_DRRK")["board_type"] == "kanban"
# --- журнал изменений: history в выдаче refresh, sync_run.history_at (#70, Task 3) ----------------------------------
def _full_run(conn, result, started_at=None, status="done"):
"""Полный refresh: поставить, выдать, отчитаться; started_at — подменить время начала прогона."""
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
c = queue.claim(conn)
if started_at:
conn.execute("UPDATE sync_run SET started_at = ? WHERE command_id = ?", (started_at, c["id"]))
queue.report_result(conn, c["id"], status, result=result)
return c
def test_first_full_refresh_gets_backfill(conn, add_project): # сценарий «Первый refresh после выкатки»
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
got = queue.claim(conn)
assert got["params"]["history"] == {
"jql": '(project = AURORA AND labels in ("AWPCA")) AND sprint in openSprints() AND statusCategory != Done'}
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
def test_regular_refresh_gets_since(conn, add_project): # «Регулярный refresh» и «Журнал не дочитан»
add_project(conn)
_full_run(conn, {"history": {"issues": 1, "changes": 2, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
_full_run(conn, {"history": {"error": "HTTP 500"}}, "2026-10-22T10:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
assert queue.claim(conn)["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
def test_new_project_gets_since_and_backfill(conn, add_project): # «Новый проект»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
add_project(conn, "STOCKS_DRRK", board_id=5120)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
h = queue.claim(conn)["params"]["history"]
assert h["since"] == "2026-10-22T08:45:00Z"
assert h["jql"] == "(" + projects.base_jql(conn) + ") AND sprint in openSprints() AND statusCategory != Done"
assert "STOCKS_DRRK" in h["jql"]
def test_tail_refresh_since_and_no_mark(conn, add_project): # «Хвост пакета»
add_project(conn)
_full_run(conn, {"history": {"issues": 0, "changes": 0, "incomplete": []}}, "2026-10-22T09:00:00+00:00")
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
c = queue.claim(conn)
assert c["params"]["history"] == {"since": "2026-10-22T08:45:00Z"}
queue.report_result(conn, c["id"], "done", result={"history": {"issues": 0, "changes": 0, "incomplete": []}})
assert conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()["history_at"] is None
def test_tail_refresh_without_mark_gets_nothing(conn, add_project): # «Хвост пакета до первой отметки»
add_project(conn)
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn)["params"]
def test_no_projects_no_history(conn):
queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}])
assert "history" not in queue.claim(conn)["params"]
def test_redelivery_recomputes_history(conn, add_project): # Review Focus 3
add_project(conn)
b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": projects.base_jql(conn)}}])
first = queue.claim(conn)
again = queue.claim(conn) # отчёта нет — довыдача
assert again["id"] == first["id"] and again["params"]["history"] == first["params"]["history"]
assert "history" not in queue.batch_view(conn, b["id"])["commands"][0]["params"]
@pytest.mark.parametrize("result, status, marked", [
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "done", True),
({"history": {"issues": 1, "changes": 2, "incomplete": ["X"], "backfill_error": "HTTP 400"}}, "done", True),
({"history": {"issues": 0, "changes": 0, "error": ""}}, "done", True),
({"history": {"error": "HTTP 500"}}, "done", False),
({"issues": 3}, "done", False), # агент v12
({"history": "мусор"}, "done", False),
(None, "done", False),
({"history": {"issues": 3, "changes": 7, "incomplete": []}}, "failed", False),
])
def test_report_sets_history_at(conn, add_project, result, status, marked):
add_project(conn)
c = _full_run(conn, result, status=status)
run = conn.execute("SELECT history_at FROM sync_run WHERE command_id = ?", (c["id"],)).fetchone()
assert (run["history_at"] is not None) == marked