jira-agent/docs/superpowers/plans/2026-10-22-change-history-agent.md
Andrey Epifancev 285cc1c484 План: журнал изменений задач, агент v13 (#39)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:04:35 +04:00

14 KiB
Raw Blame History

Журнал изменений задач (агент v13) — 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: агент v13 по параметру history команды refresh читает журнал изменений (changelog) Jira — по изменившимся задачам (since) и/или стартовой JQL (jql) — и отправляет его хабу POST /agent/history; итог — в результате refresh.

Architecture: шаг журнала — в конце Executor.do_refresh, после отправки всех пачек состояния; разбор журнала задачи в элементы — чистая функция; ошибки ответа (не 2xx) Jira и хаба на этом шаге деградируют в history.error / history.backfill_error, обрыв связи — как во всём refresh. Python 3.9, только stdlib.

Tech Stack: Python 3.9 (/usr/bin/python3), stdlib, unittest, фейки tests/fake_jira.py, tests/fake_hub.py.

Spec: /Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/history/openspec/changes/change-history/ — design.md (решения 7–11, 17), specs/agent/spec.md («Журнал изменений»), specs/commands/spec.md (форма history), specs/hub-api/spec.md («Приём журнала изменений»). Решения агента — openspec/changes/agent-v13-change-history/design.md этого репозитория (таблица R-n).

Worktree: /Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/history, ветка feat/39-change-history (aep/jira-agent#39). Все команды — из корня worktree.

Global Constraints

  • AGENT_VERSION = 13.
  • Python 3.9 stdlib: без datetime.fromisoformat для времени Jira с +0300 и Z (3.9 их не разбирает) — свой разбор; без match, без X | Y в аннотациях (tests/test_py39.py).
  • Запросы журнала: search jql = key in (...) (≤ 100 ключей), fields = key, expand = changelog, maxResults = 100; стартовая — search по history.jql с теми же fields/expand, постранично; дочитывание — GET /rest/api/2/issue/{key} с fields = key, expand = changelog; поиск id поля — GET /rest/api/2/field, не больше одного раза за refresh.
  • Пачки POST /agent/history — до 500 элементов, тело {command_id, changes}.
  • Результат: history = {issues, changes, incomplete} + error / backfill_error по спеке; без history в параметрах — ключа нет.
  • Лог — функция log(...) агента, тексты по-русски.
  • Каждый коммит — с (#39) в заголовке и последней строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
  • Зелёный — весь набор: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .
  • Решения, не записанные в спеке и плане, — в таблицу openspec/changes/agent-v13-change-history/design.md (маркер R-n — продолжение нумерации хабовой фичи, дата, решение, обоснование, цена ошибки). Находки ревью задачи n — T<n>.<k>.

Review Focus

  1. Время Jira в формах 2026-10-22T13:05:11.000+0300, …+03:00, …Z, без дробной части; since от хаба с Z — сравнение моментов верное на границе (updated == since входит) (тесты в Task 1).
  2. Запись журнала, в которой одни элементы годные, а другой с пустым field, — годные отправляются, плохой отброшен с логом, хаб не получает 422 (тест в Task 1).
  3. Хаб ответил 422 на пачку журнала — refresh done, history.error с префиксом «хаб: », повтора команды нет; хаб недоступен (ConnectionError) — refresh failed с retryable (тесты в Task 2).
  4. history = {since, jql} и задача попала и в изменившиеся, и в стартовую выборку — её журнал читается и отправляется один раз (тест в Task 2).
  5. Соседи, дочитанные в этом refresh, с updated >= since — их журнал тоже читается (тест в Task 2).

Task 1: Фейк журнала Jira и разбор журнала задачи

Модель исполнителя: sonnet.

Files:

  • Modify: tests/fake_jira.py — журнал задач, expand=changelog, GET /rest/api/2/field, заданные результаты JQL
  • Modify: src/agent.py — _jira_time, Executor.changelog_items
  • Test: tests/test_history.py (новый)

Interfaces:

  • Produces (tests/fake_jira.py):

    • self.changelogs = {} — ключ → список записей Jira {"id", "author": {"name", "displayName"}, "created", "items": [{"field", "fieldtype", "fieldId"?, "from", "fromString", "to", "toString"}]};
    • add_history(key, created, items, hid=None, name="petrov", display="Петров Пётр") -> str (id записи);
    • self.changelog_cap = None — сколько записей отдаёт search/GET /issue (None — все; total — всегда полное число);
    • self.issue_changelog_cap = None — то же только для GET /issue/{key}?expand=changelog;
    • self.jql_results = {} — JQL → список ключей (проверяется в search_keys первым);
    • self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, …] — ответ GET /rest/api/2/field;
    • expand=changelog в search и GET /issue/{key} добавляет к задаче changelog: {startAt: 0, maxResults, total, histories}.
  • Produces (src/agent.py):

    • _jira_time(value) -> Optional[datetime] — aware datetime или None; формы — Review Focus 1.
    • Executor.changelog_items(self, key: str, histories: List[dict], since: Optional[datetime], field_id_of: Callable[[str], Optional[str]]) -> List[dict] — элементы для хаба по спеке agent «Журнал изменений» (форма элемента, отбрасывание записей без id/с пустым created, элементов с пустым field, отсечение created < since при since); field_id_of(name) вызывается только для fieldtype == "custom" без fieldId; нестроковые from/to — str(...).
  • Step 1: Write the failing tests

# tests/test_history.py (unittest, как test_refresh.py)
def test_jira_time_forms(self):
    for raw in ("2026-10-22T13:05:11.000+0300", "2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11Z", "2026-10-22T10:05:11.000Z"):
        self.assertEqual(agent._jira_time(raw), datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc))
    self.assertIsNone(agent._jira_time("мусор")); self.assertIsNone(agent._jira_time(None))

def test_changelog_items_since_and_shape(self):
    histories = [
        {"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300",
         "items": [{"field": "status", "fieldtype": "jira", "from": "1", "fromString": "Открыта", "to": "3", "toString": "В работе"}]},
        {"id": "2", "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300",
         "items": [{"field": "status", "fieldtype": "jira", "fieldId": "status", "from": "3", "fromString": "В работе", "to": "10001", "toString": "Готово"},
                   {"field": "resolution", "fieldtype": "jira", "from": None, "fromString": None, "to": "1", "toString": "Готово"}]}]
    items = self.ex.changelog_items("X", histories, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
    self.assertEqual([(i["history_id"], i["seq"]) for i in items], [("2", 0), ("2", 1)])
    self.assertEqual(items[0], {"history_id": "2", "seq": 0, "key": "X", "created": "2026-10-22T12:10:00.000+0300",
                                "author": "ivanov", "field_id": "status", "field_name": "status", "from": "3",
                                "from_string": "В работе", "to": "10001", "to_string": "Готово"})
    self.assertEqual(items[1]["field_id"], "resolution")

def test_changelog_items_custom_field_lookup(self):   # fieldtype custom без fieldId → field_id_of("Плановое окончание") = "customfield_10201"
def test_changelog_items_drops_bad(self):             # запись без id, запись с created "", элемент с field "" — отброшены, остальные на месте, строки в логе
def test_changelog_items_no_since(self):              # since=None — все записи
  • Step 2: Run /usr/bin/python3 -m unittest tests.test_history -v — FAIL
  • Step 3: Implement фейк и функции.
  • Step 4: Run весь набор (Global Constraints) — зелёный
  • Step 5: Commit — Разбор журнала изменений Jira и фейк журнала (#39)

Task 2: Шаг журнала в refresh, POST /agent/history, версия 13

Модель исполнителя: sonnet.

Files:

  • Modify: src/agent.py — AGENT_VERSION = 13; HubClient.history(payload) -> dict; Executor.refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]; вызов из do_refresh после отправки пачек, result["history"], если не None
  • Modify: tests/fake_hub.py — маршрут POST /agent/history: копит тела в self.histories, self.reject_history = None | (код, тело) для ответа ошибкой
  • Modify: ARCHITECTURE.md (поток журнала в refresh), README.md (если перечисляет маршруты хаба/возможности версии)
  • Test: tests/test_history.py

Interfaces:

  • Consumes: _jira_time, Executor.changelog_items (Task 1); self.search_all-подобная пагинация с expand.

  • Produces: refresh_history — None, если "history" нет в p или не словарь; иначе словарь итога. Порядок: (1) регулярная часть по p["history"]["since"] (если есть) над fetched = все сырые задачи этого refresh (база + соседи); (2) стартовая по p["history"]["jql"] (если есть), пропуская ключи, уже прочитанные в (1); (3) поиск id поля — лениво, один GET /field на refresh; (4) дочитывание неполного журнала; (5) отправка пачками по 500. HttpError от Jira в (1), (3), (4) или любой HttpError хаба в (5) → error (текст "HTTP <код> <текст[:500]>", у хаба — "хаб: …"), отправленное до ошибки учитывается в issues/changes; HttpError в search стартовой части → backfill_error, остальное продолжается. ConnectionError не ловится (хаба — через self._hub, станет HubError, как сейчас).

  • Step 1: Write the failing tests — сценарии спеки agent «Журнал изменений» (все восемь) и Review Focus 3–5:

def test_nothing_changed_no_changelog_calls(self):
    # 3 задачи, updated "2026-10-01…"; refresh {jql: base, history: {since: "2026-10-22T08:45:00Z"}}
    out = self.ex.run({"id": 1, "type": "refresh", "params": {...}})
    self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []})
    self.assertFalse([q for q in self.fake.queries if q.get("expand") == "changelog"])

def test_two_changed_one_search(self):            # X, Y updated после since → один search key in (X,Y) expand=changelog; 3 элемента в hub.histories
def test_backfill_by_jql(self):                   # fake.jql_results[history_jql] = 12 ключей → журнал целиком; запрос по history.jql
def test_since_and_jql_dedup(self):               # ключ в обеих частях — один раз в отправке
def test_neighbor_changed_included(self):         # сосед, дочитанный в этом refresh, с updated после since — его журнал отправлен
def test_jira_500_degrades(self):                 # fail_next=(500) на search с expand → out["status"] == "done", history.error начинается с "HTTP 500", нет "retryable"
def test_backfill_400(self):                      # 400 на history.jql, X изменилась → журнал X отправлен, backfill_error, нет error
def test_incomplete(self):                        # changelog_cap=issue_changelog_cap=100 при 250 записях → incomplete ["X"], в логе "X: получено 100 из 250"
def test_hub_422_degrades(self):                  # hub.reject_history=(422, …) → done, history.error начинается с "хаб: "
def test_hub_down_fails(self):                    # хаб остановлен перед шагом журнала → failed, retryable
def test_batches_of_500(self):                    # 600 элементов → две пачки 500 и 100
def test_no_history_param(self):                  # нет ключа history в результате, нет запросов с expand
  • Step 2: Run /usr/bin/python3 -m unittest tests.test_history -v — FAIL
  • Step 3: Implement, AGENT_VERSION = 13, документация.
  • Step 4: Run весь набор (Global Constraints) — зелёный; tests/test_py39.py зелёный
  • Step 5: Commit — Агент v13: журнал изменений в refresh (#39)