План: журнал изменений задач, хаб (#70)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:04:35 +04:00
parent 5a6cdecc8e
commit ba7ac9fb41

View File

@ -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 <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)`