diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b82c633..00f1b26 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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/` → `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` и своих копий не держит. diff --git a/CLAUDE.md b/CLAUDE.md index 0352b92..00ebcc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/*`, клиентский — остальное. ## Архитектура diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index dd4400d..53b59b0 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index dab86e3..7eb2564 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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())