MCP: лента изменений, история в issue, журнал в sync_log (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:23:39 +04:00
parent 337e36f1a0
commit a27ebc9586
4 changed files with 219 additions and 9 deletions

View File

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

View File

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

View File

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

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