sdlc-hub/docs/superpowers/plans/2026-10-22-change-history-hub.md
Andrey Epifancev ba7ac9fb41 План: журнал изменений задач, хаб (#70)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:04:52 +04:00

296 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Журнал изменений задач (хаб) — 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)`