24 KiB
Журнал изменений задач (хаб) — 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(приём журнала), 422bad_request(параметры ленты), 404not_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
- Время Jira в неожиданной форме (
+03:00,Z, без дробной части, без смещения, мусор,None) —db.jira_utcне падает: разбирает или возвращаетNone; комментарий с мусорнымcreatedзаписывается сcreated_at = NULLи в ленту не попадает (тесты в Task 1). - Агент v12 присылает результат
refreshбезhistory, илиhistoryне словарь, илиhistory.errorпустая строка — отчёт принимается,history_atне ставится только при наличии непустогоerrorили отсутствии/не-словареhistory(тесты в Task 3). - Повторная выдача
refresh(довыдача после потери отчёта) —historyдописывается заново по текущей отметке; в базеparamsбезhistory(тест в Task 3). - Лента по задаче, которой нет в зеркале (журнал пришёл раньше состояния или задача ушла из выборки) — событие отдаётся с
summary = null,status = null, не теряется и не роняет запрос (тест в Task 4). - 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(422bad_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при мусоре → 422bad_request«since: …»),since > until→ 422since,limitвне 1..2000 → 422limit, неизвестный вид → 422kinds, проект —_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)