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

24 KiB
Raw Permalink Blame History

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

# 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

# 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:

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:

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:

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)