diff --git a/docs/superpowers/plans/2026-10-22-change-history-agent.md b/docs/superpowers/plans/2026-10-22-change-history-agent.md new file mode 100644 index 0000000..1dba683 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-change-history-agent.md @@ -0,0 +1,141 @@ +# Журнал изменений задач (агент 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 `. +- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +- Решения, не записанные в спеке и плане, — в таблицу `openspec/changes/agent-v13-change-history/design.md` (маркер `R-n` — продолжение нумерации хабовой фичи, дата, решение, обоснование, цена ошибки). Находки ревью задачи n — `T.`. + +## 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** + +```python +# 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: + +```python +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)`