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

142 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Журнал изменений задач (агент 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**
```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)`