MCP: лента изменений, история в issue, журнал в sync_log (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
337e36f1a0
commit
a27ebc9586
@ -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` и своих копий не держит.
|
||||
|
||||
@ -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/*`, клиентский — остальное.
|
||||
|
||||
## Архитектура
|
||||
|
||||
@ -7,6 +7,7 @@ import inspect
|
||||
import json
|
||||
import stat
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
@ -141,10 +142,66 @@ def fmt_issue(d) -> str:
|
||||
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
|
||||
else:
|
||||
cmts = ["комментариев нет"]
|
||||
changes = d.get("changes") or []
|
||||
if changes:
|
||||
cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \
|
||||
([f" ещё {len(changes) - 5}"] if len(changes) > 5 else [])
|
||||
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
|
||||
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
|
||||
|
||||
|
||||
DATE_LABELS = {"duedate": "срок", "planned_start": "плановое начало", "planned_end": "плановое окончание"}
|
||||
|
||||
|
||||
def _day(value, text) -> str:
|
||||
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
|
||||
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
|
||||
return f"{value[8:10]}.{value[5:7]}"
|
||||
return _v(text)
|
||||
|
||||
|
||||
def fmt_event(e: dict, today) -> str:
|
||||
"""Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today
|
||||
(today = None — дата всегда)."""
|
||||
if e.get("at"):
|
||||
local = datetime.fromisoformat(e["at"]).astimezone()
|
||||
when = local.strftime("%H:%M") if today is not None and local.date() == today else local.strftime("%d.%m %H:%M")
|
||||
else:
|
||||
when = "—"
|
||||
kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
|
||||
if kind == "comment":
|
||||
what = "комментарий: " + _cut(to, 200)
|
||||
elif kind == "status":
|
||||
what = f"статус «{_v(frm)}» → «{_v(to)}»"
|
||||
elif kind == "description":
|
||||
what = "описание изменено"
|
||||
elif kind == "dates":
|
||||
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
|
||||
f"{_day(e.get('to_value'), to)}")
|
||||
else:
|
||||
what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»"
|
||||
return f"{when} {_v(e.get('author'))}: {what}"
|
||||
|
||||
|
||||
def fmt_changes(data: dict, now: datetime) -> str:
|
||||
"""Лента по задачам: задачи — по последнему событию (лента уже новые сверху), под задачей — её события."""
|
||||
def span(at):
|
||||
return datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M")
|
||||
lines = [f"изменения с {span(data['since'])} по {span(data['until'])}"]
|
||||
events = data.get("changes") or []
|
||||
if not events:
|
||||
return "\n".join(lines + ["за период изменений нет"])
|
||||
groups: dict[str, list] = {}
|
||||
for e in events:
|
||||
groups.setdefault(e["key"], []).append(e)
|
||||
for key, items in groups.items():
|
||||
lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}")
|
||||
lines += [" " + fmt_event(e, now.date()) for e in items]
|
||||
if data.get("truncated"):
|
||||
lines.append(f"показаны последние {len(events)} событий, сузьте период")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_batch(b) -> str:
|
||||
est = round((b.get("remaining_seconds") or 0) / 60)
|
||||
lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; "
|
||||
@ -204,6 +261,29 @@ def issue(key: str) -> str:
|
||||
return fmt_issue(_client().get(f"/issues/{key}"))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
|
||||
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
|
||||
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 со смещением; kinds — через запятую из
|
||||
status, description, dates, comment, other или all; key — одна задача (без привязки к проекту)."""
|
||||
if hours is not None and since:
|
||||
return "либо hours, либо since"
|
||||
if hours is not None and hours <= 0:
|
||||
return "hours — число больше 0"
|
||||
refusal = _unbound_or_unknown(required=not key) # с key — любая задача, как issue
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
now = datetime.now().astimezone()
|
||||
if hours is not None:
|
||||
since = (now - timedelta(hours=hours)).isoformat()
|
||||
elif not since:
|
||||
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
data = _client().get("/changes", since=since, until=until, kinds=kinds, key=key,
|
||||
project=None if key else PROJECT)
|
||||
return fmt_changes(data, now)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@guard
|
||||
@scoped(True)
|
||||
@ -379,9 +459,13 @@ def agent_status() -> str:
|
||||
def sync_log(limit: int = 10) -> str:
|
||||
"""Журнал обновлений зеркала."""
|
||||
runs = _client().get("/sync-runs", limit=limit)["runs"]
|
||||
|
||||
def journal(r):
|
||||
at = r.get("history_at")
|
||||
return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет"
|
||||
return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, "
|
||||
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}"
|
||||
for r in runs) or "обновлений не было"
|
||||
f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}, "
|
||||
f"{journal(r)}" for r in runs) or "обновлений не было"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
|
||||
@ -159,7 +159,7 @@ def test_sync_log_content(monkeypatch):
|
||||
runs = [{"id": 3, "kind": "full", "query": "project = AWPCA", "started_at": "2026-10-11T10:00", "finished_at": None,
|
||||
"issues_count": 12, "links_count": 5, "jira_calls": 4, "rate_limited": 1}]
|
||||
monkeypatch.setattr(mcp_server, "_client", lambda: FakeClient(["done"], runs))
|
||||
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1"
|
||||
assert mcp_server.sync_log() == "#3 full project = AWPCA: 2026-10-11T10:00 → —, задач 12, связей 5, вызовов 4, 429: 1, журнал: нет"
|
||||
|
||||
|
||||
def test_async_tools_are_coroutines_and_registered():
|
||||
@ -492,3 +492,123 @@ def test_run_binds_project(monkeypatch):
|
||||
monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None)
|
||||
mcp_server.run("AWPCA")
|
||||
assert mcp_server.PROJECT == "AWPCA"
|
||||
|
||||
|
||||
# --- лента изменений (#70, Task 5) ------------------------------------------------------------------------------
|
||||
|
||||
from datetime import datetime, timedelta, timezone # noqa: E402
|
||||
|
||||
|
||||
def _local(at: str) -> datetime:
|
||||
return datetime.fromisoformat(at).astimezone()
|
||||
|
||||
|
||||
def _ev(**kw):
|
||||
base = {"at": "2026-10-22T08:15:00+00:00", "key": "X", "summary": "Тема", "status": "Готово", "kind": "status",
|
||||
"field": "status", "author": "Петров Пётр", "from": "В работе", "to": "Готово", "from_value": None,
|
||||
"to_value": None}
|
||||
return {**base, **kw}
|
||||
|
||||
|
||||
def test_fmt_event_status():
|
||||
e = _ev()
|
||||
hm = _local(e["at"]).strftime("%H:%M")
|
||||
assert mcp_server.fmt_event(e, _local(e["at"]).date()) == f"{hm} Петров Пётр: статус «В работе» → «Готово»"
|
||||
|
||||
|
||||
def test_fmt_event_other_day_has_date():
|
||||
e = _ev()
|
||||
d = _local(e["at"])
|
||||
assert mcp_server.fmt_event(e, d.date() + timedelta(days=1)).startswith(d.strftime("%d.%m %H:%M "))
|
||||
assert mcp_server.fmt_event(e, None).startswith(d.strftime("%d.%m %H:%M "))
|
||||
|
||||
|
||||
def test_fmt_event_dates_by_value():
|
||||
e = _ev(kind="dates", field="duedate", **{"from": "25/окт/26", "to": None}, from_value="2026-10-25", to_value=None)
|
||||
assert mcp_server.fmt_event(e, None).endswith(": срок 25.10 → —")
|
||||
e = _ev(kind="dates", field="planned_end", **{"from": "сырое", "to": "30/окт/26"}, from_value=None, to_value="2026-10-30 00:00:00.0")
|
||||
assert mcp_server.fmt_event(e, None).endswith(": плановое окончание сырое → 30.10")
|
||||
|
||||
|
||||
def test_fmt_event_kinds():
|
||||
assert mcp_server.fmt_event(_ev(kind="description", field="description"), None).endswith(": описание изменено")
|
||||
long = "к" * 250
|
||||
out = mcp_server.fmt_event(_ev(kind="comment", field="comment", **{"from": None, "to": long}), None)
|
||||
assert out.endswith(": комментарий: " + "к" * 199 + "…")
|
||||
out = mcp_server.fmt_event(_ev(kind="other", field="assignee", **{"from": None, "to": "ivanov"}), None)
|
||||
assert out.endswith(": assignee: «—» → «ivanov»")
|
||||
|
||||
|
||||
def test_fmt_changes_grouped():
|
||||
now = datetime(2026, 10, 22, 14, 5, tzinfo=timezone.utc).astimezone()
|
||||
data = {"since": "2026-10-21T21:00:00+00:00", "until": "2026-10-22T11:05:00+00:00", "truncated": True, "changes": [
|
||||
_ev(at="2026-10-22T09:00:00+00:00", key="Y", summary="Вторая", status="В работе"),
|
||||
_ev(at="2026-10-22T08:15:00+00:00"),
|
||||
_ev(at="2026-10-22T08:00:00+00:00", key="Y", summary="Вторая", status="В работе", kind="description",
|
||||
field="description")]}
|
||||
lines = mcp_server.fmt_changes(data, now).split("\n")
|
||||
assert lines[0].startswith("изменения с ")
|
||||
assert lines[1] == "Y Вторая — В работе" and lines[2].startswith(" ") and lines[3].endswith("описание изменено")
|
||||
assert lines[4] == "X Тема — Готово"
|
||||
assert lines[-1] == "показаны последние 3 событий, сузьте период"
|
||||
|
||||
|
||||
def test_fmt_changes_empty():
|
||||
out = mcp_server.fmt_changes({"since": "2026-10-22T10:00:00+00:00", "until": "2026-10-22T11:00:00+00:00",
|
||||
"truncated": False, "changes": []}, datetime.now().astimezone())
|
||||
assert out.split("\n")[1] == "за период изменений нет"
|
||||
|
||||
|
||||
def _history(live, key, n, created="2026-10-22T08:00:00Z", **kw):
|
||||
items = [{"history_id": f"{key}{i}", "seq": 0, "key": key, "created": created, "author": "Петров",
|
||||
"field_id": "status", "field_name": "status", "from": None, "from_string": "a", "to": None,
|
||||
"to_string": f"s{i}", **kw} for i in range(n)]
|
||||
live["agent"].post("/agent/history", {"changes": items})
|
||||
|
||||
|
||||
def test_changes_today(live): # «Что закрылось сегодня» / «За два часа»
|
||||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "status": "Готово",
|
||||
"labels": ["AWPCA"]}], "links": []})
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
_history(live, "A-1", 1, created=(now - timedelta(minutes=30)).isoformat())
|
||||
_history(live, "B-1", 1, created=(now - timedelta(minutes=30)).isoformat()) # не в проекте
|
||||
out = mcp_server.changes(kinds="status")
|
||||
assert "A-1 Тема — Готово" in out and "статус «a» → «s0»" in out and "B-1" not in out
|
||||
assert "A-1" in mcp_server.changes(hours=2)
|
||||
assert "B-1" in mcp_server.changes(key="B-1")
|
||||
|
||||
|
||||
def test_changes_hours_and_since(live):
|
||||
assert mcp_server.changes(hours=2, since="2026-10-22T10:00:00Z") == "либо hours, либо since"
|
||||
assert mcp_server.changes(hours=0) == "hours — число больше 0"
|
||||
|
||||
|
||||
def test_changes_bad_since(live):
|
||||
assert mcp_server.changes(since="вчера").startswith("ошибка хаба 422")
|
||||
|
||||
|
||||
def test_changes_empty(live):
|
||||
assert mcp_server.changes(hours=1).split("\n")[1] == "за период изменений нет"
|
||||
|
||||
|
||||
def test_changes_unbound(live, monkeypatch):
|
||||
monkeypatch.setattr(mcp_server, "PROJECT", None)
|
||||
assert mcp_server.changes() == mcp_server.NOT_BOUND
|
||||
assert mcp_server.changes(key="A-1").split("\n")[1] == "за период изменений нет"
|
||||
|
||||
|
||||
def test_issue_shows_changes(live): # «История в карточке»
|
||||
live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "Тема", "labels": ["AWPCA"]}], "links": []})
|
||||
assert "изменения (" not in mcp_server.issue("A-1")
|
||||
_history(live, "A-1", 7)
|
||||
out = mcp_server.issue("A-1")
|
||||
assert "изменения (7):" in out and " ещё 2" in out and out.count("статус «a»") == 5
|
||||
|
||||
|
||||
def test_sync_log_history(live):
|
||||
asyncio.run(mcp_server.refresh(full=True))
|
||||
cmd = live["agent"].post("/agent/claim", {})["command"]
|
||||
assert "журнал: нет" in mcp_server.sync_log()
|
||||
live["agent"].post(f"/agent/commands/{cmd['id']}/result",
|
||||
{"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}})
|
||||
assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user