diff --git a/docs/superpowers/plans/2026-10-22-change-history-hub.md b/docs/superpowers/plans/2026-10-22-change-history-hub.md new file mode 100644 index 0000000..5d407ea --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-change-history-hub.md @@ -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 `. +- Зелёным считается только весь набор: `uv run pytest` (фронт не меняется; `make check` — в финальной задаче). +- Любое решение исполнителя, не записанное в спеке или плане, дописывается в `openspec/changes/change-history/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки) с маркером `R-n`. Находки ревью задачи n — `T.`. + +## 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 = 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)`