План: журнал изменений задач, агент v13 (#39)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 10:04:35 +04:00
parent ea2e150f9a
commit 285cc1c484

View File

@ -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 <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)`