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