From ea2e150f9aa66709245c841115d6483c974875cd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 09:10:06 +0400 Subject: [PATCH 1/7] =?UTF-8?q?OpenSpec=20agent-v13-change-history:=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../agent-v13-change-history/.openspec.yaml | 2 ++ .../changes/agent-v13-change-history/design.md | 14 ++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 openspec/changes/agent-v13-change-history/.openspec.yaml create mode 100644 openspec/changes/agent-v13-change-history/design.md diff --git a/openspec/changes/agent-v13-change-history/.openspec.yaml b/openspec/changes/agent-v13-change-history/.openspec.yaml new file mode 100644 index 0000000..7cc41bb --- /dev/null +++ b/openspec/changes/agent-v13-change-history/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-22 diff --git a/openspec/changes/agent-v13-change-history/design.md b/openspec/changes/agent-v13-change-history/design.md new file mode 100644 index 0000000..4eb0385 --- /dev/null +++ b/openspec/changes/agent-v13-change-history/design.md @@ -0,0 +1,14 @@ +# agent-v13-change-history — решения агента + +Спека и дизайн изменения — в `jira-hub/openspec/changes/change-history/` (issue aep/jira-hub#70, aep/jira-agent#39). +Агентская часть — решения 7–11 и 17 дизайна хаба и требование `agent` «Журнал изменений» (ADDED); контракт команд — +`commands` «Журнал изменений в refresh» (`refresh.history`, результат `history`); приём — `hub-api` «Приём журнала +изменений» (`POST /agent/history`). Версия агента — 13. + +## Decisions made without discussion + +Решения хаба записаны в `jira-hub/openspec/changes/change-history/design.md` (раздел «Decisions made without +discussion»); здесь — только решения агентской части, принятые при исполнении. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| From 285cc1c4848915253e2e4d4230d090517538a69e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:04:35 +0400 Subject: [PATCH 2/7] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B6=D1=83?= =?UTF-8?q?=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87,=20=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=20v13=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-22-change-history-agent.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-change-history-agent.md 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)` From de2d5b52522e3ea38d03e7dab8eecd4ae768d641 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:27:00 +0400 Subject: [PATCH 3/7] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0=20=D0=B8=D0=B7=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20Jira=20=D0=B8=20=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D0=BA=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B0?= =?UTF-8?q?=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 51 +++++++++++++++++++ tests/fake_jira.py | 38 +++++++++++++-- tests/test_history.py | 111 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 tests/test_history.py diff --git a/src/agent.py b/src/agent.py index f048097..90e8d26 100644 --- a/src/agent.py +++ b/src/agent.py @@ -526,6 +526,27 @@ def _parse_at(value) -> float: # --------------------------------------------------------------------------- # Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу # --------------------------------------------------------------------------- +def _text(value: Any) -> Optional[str]: + return None if value is None else str(value) + + +def _jira_time(value: Any) -> Optional[datetime]: + """Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → aware datetime; без + смещения — UTC; иначе None. В 3.9 fromisoformat не разбирает ни `Z`, ни `+0300`.""" + if not isinstance(value, str) or not value: + return None + s = value[:-1] + "+0000" if value.endswith("Z") else value + if len(s) > 6 and s[-6] in "+-" and s[-3] == ":": # ±HH:MM → ±HHMM + s = s[:-3] + s[-2:] + for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"): + try: + d = datetime.strptime(s, fmt) + except ValueError: + continue + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) + return None + + def _date(value) -> Optional[str]: """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню.""" return (value or "")[:10] or None @@ -983,6 +1004,36 @@ class Executor: out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) return out + def changelog_items(self, key: str, histories: List[dict], since: Optional[datetime], + field_id_of: Callable[[str], Optional[str]]) -> List[dict]: + """Записи журнала задачи → элементы для хаба (по одному на поле записи). Записи без id или времени и элементы + без поля отбрасываются с записью в лог (хаб отверг бы всю пачку); since — только записи не раньше него. + field_id: fieldId элемента, у custom без него — по имени поля (field_id_of), у прочих — само поле.""" + out: List[dict] = [] + for h in histories: + hid, created = h.get("id"), h.get("created") + if hid in (None, "") or not created: + log("журнал %s: запись без id или времени отброшена" % key) + continue + at = _jira_time(created) + if since is not None and (at is None or at < since): + continue + a = h.get("author") or {} + author = a.get("displayName") or a.get("name") or None + for seq, it in enumerate(h.get("items") or []): + name = it.get("field") + if not name: + log("журнал %s: элемент без поля в записи %s отброшен" % (key, hid)) + continue + fid = it.get("fieldId") + if not fid: + fid = field_id_of(name) if it.get("fieldtype") == "custom" else name + out.append({"history_id": str(hid), "seq": seq, "key": key, "created": created, "author": author, + "field_id": fid, "field_name": name, "from": _text(it.get("from")), + "from_string": _text(it.get("fromString")), "to": _text(it.get("to")), + "to_string": _text(it.get("toString"))}) + return out + @staticmethod def comment_state(c: dict) -> dict: """Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira.""" diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 24a70a2..4a4b7a9 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -87,6 +87,14 @@ class FakeJira: self.bad_attachment_response = False # ответить на загрузку пустым списком self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira + self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}] + self.history_seq = 50000 + self.changelog_cap = None # сколько записей журнала отдаёт search и GET /issue (None — все); total — полное + self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap) + self.jql_results = {} # JQL → ключи (проверяется в search_keys первым) + self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, + {"id": "customfield_10104", "name": "Sprint", "custom": True}, + {"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field self.lock = threading.Lock() fake = self @@ -133,6 +141,19 @@ class FakeJira: self.issues[key] = base return base + def add_history(self, key, created, items, hid=None, name="petrov", display="Петров Пётр"): + """Запись журнала задачи; возвращает её id.""" + self.history_seq += 1 + hid = hid or str(self.history_seq) + self.changelogs.setdefault(key, []).append({"id": hid, "author": {"name": name, "displayName": display}, + "created": created, "items": items}) + return hid + + def changelog_json(self, key, cap): + lst = self.changelogs.get(key, []) + shown = lst if cap is None else lst[:cap] + return {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), "histories": shown} + def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300", updated=None): author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None @@ -186,6 +207,8 @@ class FakeJira: return out def search_keys(self, jql): + if jql in self.jql_results: + return [k for k in self.jql_results[jql] if k in self.issues] m = re.search(r"key in \(([^)]*)\)", jql or "") if m: keys = [k.strip() for k in m.group(1).split(",")] @@ -261,8 +284,13 @@ class FakeJira: start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) page = keys[start:start + maxr] fields = (q.get("fields") or "").split(",") if q.get("fields") else [] - return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), - "issues": [self.issue_json(k, fields) for k in page]} + issues = [self.issue_json(k, fields) for k in page] + if q.get("expand") == "changelog": + for it in issues: + it["changelog"] = self.changelog_json(it["key"], self.changelog_cap) + return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": issues} + if p == "/rest/api/2/field": + return 200, self.fields if p == "/rest/api/2/issue" and method == "POST": self.seq += 1 key = "%s-%d" % (self.project, self.seq) @@ -290,7 +318,11 @@ class FakeJira: if m: key = m.group(1) if method == "GET": - return 200, self.issue_json(key) + out = self.issue_json(key) + if q.get("expand") == "changelog": + cap = self.issue_changelog_cap if self.issue_changelog_cap is not None else self.changelog_cap + out["changelog"] = self.changelog_json(key, cap) + return 200, out if method == "PUT": f = self.issues[key] for k, v in body.get("fields", {}).items(): diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 0000000..97c781b --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,111 @@ +"""Журнал изменений Jira в refresh (агент v13, spec agent «Журнал изменений»).""" +import unittest +from datetime import datetime, timezone + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_hub import FakeHub +from fake_jira import FakeJira + + +def _st(field, frm, to, fieldtype="jira", **kw): + return dict({"field": field, "fieldtype": fieldtype, "from": frm, "fromString": frm, "to": to, "toString": to}, **kw) + + +class Base(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + self.logs = [] + self._log = agent.log + agent.log = self.logs.append + + def tearDown(self): + agent.log = self._log + self.fake.stop() + self.hub.stop() + + +class ChangelogItemsTest(Base): + def test_jira_time_forms(self): + want = datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc) + 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", "2026-10-22T05:05:11.000-0500"): + self.assertEqual(agent._jira_time(raw), want, raw) + for raw in ("мусор", "", None, 42): + self.assertIsNone(agent._jira_time(raw)) + + def test_changelog_items_since_and_shape(self): + histories = [ + {"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300", + "items": [_st("status", "Открыта", "В работе")]}, + {"id": 2, "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300", + "items": [dict(_st("status", "В работе", "Готово"), fieldId="status", **{"from": "3", "to": "10001"}), + _st("resolution", None, "Готово")]}] + 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") + self.assertIsNone(items[1]["from"]) + + def test_changelog_items_since_boundary_included(self): + h = [{"id": "1", "author": None, "created": "2026-10-22T11:45:00.000+0300", "items": [_st("status", "a", "b")]}] + items = self.ex.changelog_items("X", h, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None) + self.assertEqual(len(items), 1) + self.assertIsNone(items[0]["author"]) + + def test_changelog_items_custom_field_lookup(self): + asked = [] + + def field_id_of(name): + asked.append(name) + return "customfield_10201" + h = [{"id": "1", "author": {"name": "p"}, "created": "2026-10-22T12:00:00.000+0300", + "items": [_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom"), + _st("Sprint", "1", "2", fieldtype="custom", fieldId="customfield_10104"), + _st("duedate", "2026-10-25", "2026-10-30")]}] + items = self.ex.changelog_items("X", h, None, field_id_of) + self.assertEqual([i["field_id"] for i in items], ["customfield_10201", "customfield_10104", "duedate"]) + self.assertEqual(asked, ["Плановое окончание"]) + + def test_changelog_items_drops_bad(self): + h = [{"id": None, "author": None, "created": "2026-10-22T12:00:00.000+0300", "items": [_st("status", "a", "b")]}, + {"id": "2", "author": None, "created": "", "items": [_st("status", "a", "b")]}, + {"id": "3", "author": None, "created": "2026-10-22T12:00:00.000+0300", + "items": [_st("", "a", "b"), _st("status", 1, 2)]}] + items = self.ex.changelog_items("X", h, None, lambda n: None) + self.assertEqual([(i["history_id"], i["seq"], i["from"], i["to"]) for i in items], [("3", 1, "1", "2")]) + self.assertEqual(len([l for l in self.logs if "X" in l]), 3) + + def test_changelog_items_no_since(self): + h = [{"id": str(n), "author": None, "created": "2020-01-01T00:00:00.000+0300", "items": [_st("status", "a", "b")]} + for n in range(3)] + self.assertEqual(len(self.ex.changelog_items("X", h, None, lambda n: None)), 3) + + +class FakeChangelogTest(Base): + def test_fake_search_expands_changelog(self): + self.fake.add("AURORA-1") + for n in range(3): + self.fake.add_history("AURORA-1", "2026-10-22T12:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.changelog_cap = 2 + page = self.jira.get(agent.API + "/search", jql="key in (AURORA-1)", fields="key", expand="changelog") + cl = page["issues"][0]["changelog"] + self.assertEqual((cl["total"], len(cl["histories"])), (3, 2)) + self.fake.issue_changelog_cap = 3 + full = self.jira.get(agent.API + "/issue/AURORA-1", fields="key", expand="changelog") + self.assertEqual(len(full["changelog"]["histories"]), 3) + self.assertNotIn("changelog", self.jira.get(agent.API + "/issue/AURORA-1")) + self.fake.jql_results["x AND sprint in openSprints()"] = ["AURORA-1"] + self.assertEqual(self.fake.search_keys("x AND sprint in openSprints()"), ["AURORA-1"]) + self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field"))) + + +if __name__ == "__main__": + unittest.main() From 71e182a162119b70be45f0e573fe8e6c0194482d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:31:47 +0400 Subject: [PATCH 4/7] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v13:=20?= =?UTF-8?q?=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=20=D0=B8=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=B2=20refresh=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- README.md | 8 +- src/agent.py | 104 ++++++++++++++++++++++++- tests/fake_hub.py | 15 ++++ tests/test_history.py | 175 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 301 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0bc527b..8b09ddf 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, пл | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | diff --git a/README.md b/README.md index 59577e2..d1332eb 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ python3 agent.py prune | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | -| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | +| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | Правила работы: - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. @@ -203,6 +203,8 @@ python3 agent.py prune Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате `refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами выкатывается только после v12 (design хаба `projects`, решение 14). + Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая + загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`). Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -252,6 +254,10 @@ python3 agent.py prune `/issue/{key}/comment`. С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается. + С v13 после состояния `refresh` читает журнал изменений (если хаб передал `history`): по задачам этого прогона с + `updated` не раньше `history.since` и по стартовой выборке `history.jql`. Неполный журнал дочитывается + `GET /issue/{key}?expand=changelog`, всё ещё неполный — в логе «получено N из M» и в `result.history.incomplete`. + Ошибка Jira или хаба (не 2xx) на этом шаге не роняет `refresh`: текст в `result.history.error` или `backfill_error`. 5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, завершающий перевод строки) — изменение. diff --git a/src/agent.py b/src/agent.py index 90e8d26..ca5f1db 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 12 +AGENT_VERSION = 13 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new HERE = os.path.dirname(os.path.abspath(__file__)) @@ -363,6 +363,9 @@ class HubClient(Http): def known_keys(self) -> List[str]: return self.call("GET", "/agent/known-keys")["keys"] + def history(self, payload: dict) -> dict: + return self.call("POST", "/agent/history", payload) + def file(self, sha256: str) -> dict: """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" return self.call("GET", "/agent/files/%s" % sha256) @@ -1199,6 +1202,9 @@ class Executor: "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], "page": pages, "last_page": i + 100 >= len(issues)}) result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} + history = self.refresh_history(p, cmd, everything) + if history is not None: + result["history"] = history if missing: result["missing"] = list(dict.fromkeys(missing))[:50] if types: @@ -1206,6 +1212,102 @@ class Executor: return self._out("done", result) + # --- журнал изменений (v13, spec agent «Журнал изменений») ----------------------------------------------- + HISTORY_BATCH = 500 + + def search_changelog(self, jql: str) -> List[dict]: + out, start = [], 0 + while True: + page = self.jira.get(API + "/search", jql=jql, fields="key", expand="changelog", startAt=start, maxResults=100) + got = page.get("issues") or [] + out += got + start += len(got) + if not got or start >= int(page.get("total") or 0): + return out + + def full_changelog(self, raw: dict, incomplete: List[str]) -> List[dict]: + """Журнал задачи из ответа search; неполный — дочитать GET /issue, всё ещё неполный — что есть, ключ в incomplete.""" + key, cl = raw["key"], raw.get("changelog") or {} + got = list(cl.get("histories") or []) + total = cl.get("total") + if isinstance(total, int) and total > len(got): + more = (self.jira.get(API + "/issue/%s" % key, fields="key", expand="changelog").get("changelog") or {}) + more_got = list(more.get("histories") or []) + if len(more_got) > len(got): + got = more_got + if total > len(got): + log("журнал %s: получено %d из %d" % (key, len(got), total)) + incomplete.append(key) + return got + + def refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]: + """Журнал после состояния: since — задачи этого refresh с updated не раньше since, jql — стартовая загрузка. + Ответ не 2xx Jira или хаба — текст в error (since, отправка) или backfill_error (jql), refresh остаётся done; + обрыв связи — как во всём refresh.""" + h = p.get("history") + if not isinstance(h, dict): + return None + out: Dict[str, Any] = {"issues": 0, "changes": 0, "incomplete": []} + names: Dict[str, Any] = {} + + def field_id_of(name: str) -> Optional[str]: + if "map" not in names: # один GET /field за refresh, только по нужде + names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or [] + if isinstance(f, dict)} + return names["map"].get(name) + + items: List[dict] = [] + read = set() + + def collect(raws: List[dict], since: Optional[datetime]) -> None: + for raw in raws: + if raw["key"] in read: + continue + read.add(raw["key"]) + items.extend(self.changelog_items(raw["key"], self.full_changelog(raw, out["incomplete"]), since, + field_id_of)) + + if h.get("since"): + since = _jira_time(h["since"]) + try: + if since is None: + raise ValueError("history.since — неразбираемое время: %r" % h["since"]) + changed = [] + for r in fetched: + upd = _jira_time(((r.get("fields") or {}).get("updated"))) + if upd is not None and upd >= since and r["key"] not in changed: + changed.append(r["key"]) + for i in range(0, len(changed), 100): + collect(self.search_changelog("key in (%s)" % ",".join(changed[i:i + 100])), since) + except HttpError as e: + out["error"] = "HTTP %d %s" % (e.code, e.text[:500]) + log("refresh: журнал не прочитан: %s" % out["error"]) + except ValueError as e: + out["error"] = str(e) + log("refresh: %s" % e) + if h.get("jql"): + try: + collect(self.search_changelog(h["jql"]), None) + except HttpError as e: + out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500]) + log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"]) + sent_keys = set() + for i in range(0, len(items), self.HISTORY_BATCH): + chunk = items[i:i + self.HISTORY_BATCH] + try: + self.hub.history({"command_id": cmd.get("id"), "changes": chunk}) + except HttpError as e: + out["error"] = "хаб: HTTP %d %s" % (e.code, e.text[:500]) + log("refresh: журнал не принят: %s" % out["error"]) + break + except ConnectionError as e: + raise HubError(e) from None + out["changes"] += len(chunk) + sent_keys.update(c["key"] for c in chunk) + out["issues"] = len(sent_keys) + return out + + # --------------------------------------------------------------------------- # Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте # --------------------------------------------------------------------------- diff --git a/tests/fake_hub.py b/tests/fake_hub.py index 68d795c..ad9fb4d 100644 --- a/tests/fake_hub.py +++ b/tests/fake_hub.py @@ -3,6 +3,7 @@ import base64 import hashlib import json import re +import socket import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -28,6 +29,9 @@ class FakeHub: self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба) self.file_requests = 0 self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб + self.histories = [] # тела POST /agent/history + self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа + self.posts = [] # порядок POST /agent/state и /agent/history self.lock = threading.Lock() hub = self @@ -81,6 +85,10 @@ class FakeHub: if h.headers.get("Authorization") != "Bearer " + self.token: return self._send(h, 401, {"error": "unauthorized"}) path = h.path.split("?")[0] + if path == "/agent/history" and self.reject_history == "drop": + h.close_connection = True + h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит + return if path == "/agent/claim" and self.claim_wait: end = time.monotonic() + self.claim_wait while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят @@ -123,7 +131,14 @@ class FakeHub: return 200, self.settings if path == "/agent/known-keys": return 200, {"keys": list(self.known)} + if path == "/agent/history": + self.posts.append(path) + if self.reject_history: + return self.reject_history + self.histories.append(body) + return 200, {"accepted": len(body.get("changes") or []), "duplicates": 0} if path == "/agent/state": + self.posts.append(path) self.states.append(body) return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} m = re.match(r"^/agent/files/([^/]+)$", path) diff --git a/tests/test_history.py b/tests/test_history.py index 97c781b..f5b0920 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -107,5 +107,180 @@ class FakeChangelogTest(Base): self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field"))) +SINCE = "2026-10-22T08:45:00Z" +CHANGED = "2026-10-22T12:30:00.000+0300" # 09:30 UTC — позже SINCE +BASE = "project = AURORA AND labels = AWPCA" + + +class RefreshHistoryTest(Base): + def refresh(self, history=None, **params): + p = dict({"jql": BASE}, **params) + if history is not None: + p["history"] = history + return self.ex.run({"id": 5, "type": "refresh", "params": p}) + + def changelog_queries(self): + return [q for q in self.fake.queries if q.get("expand") == "changelog"] + + def sent(self): + return [c for b in self.hub.histories for c in b["changes"]] + + def test_nothing_changed_no_changelog_calls(self): # «Ничего не менялось» + for n in range(3): + self.fake.add("AURORA-%d" % n) + out = self.refresh({"since": SINCE}) + self.assertEqual(out["status"], "done") + self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []}) + self.assertEqual(self.changelog_queries(), []) + self.assertEqual(self.hub.histories, []) + + def test_two_changed_one_search(self): # «Две задачи изменились» + self.fake.add("X", updated=CHANGED) + self.fake.add("Y", updated=CHANGED) + self.fake.add("Z") + self.fake.add_history("X", "2026-10-21T12:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c"), _st("resolution", None, "Готово")]) + self.fake.add_history("Y", "2026-10-22T12:30:00.000+0300", [_st("description", "старое", "новое")]) + out = self.refresh({"since": SINCE}) + qs = self.changelog_queries() + self.assertEqual(len(qs), 1) + self.assertEqual(qs[0]["jql"], "key in (X,Y)") + self.assertEqual(qs[0]["fields"], "key") + self.assertEqual([(c["key"], c["seq"]) for c in self.sent()], [("X", 0), ("X", 1), ("Y", 0)]) + self.assertEqual(self.hub.histories[0]["command_id"], 5) + self.assertEqual(out["result"]["history"], {"issues": 2, "changes": 3, "incomplete": []}) + self.assertEqual(self.hub.posts, ["/agent/state", "/agent/history"]) # журнал — после состояния + + def test_backfill_by_jql(self): # «Стартовая загрузка» + jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE + keys = ["AURORA-%d" % n for n in range(12)] + for k in keys: + self.fake.add(k) + self.fake.add_history(k, "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.jql_results[jql] = keys + out = self.refresh({"jql": jql}) + self.assertEqual([q["jql"] for q in self.changelog_queries()], [jql]) + self.assertEqual(len(self.sent()), 12) + self.assertEqual(out["result"]["history"], {"issues": 12, "changes": 12, "incomplete": []}) + + def test_since_and_jql_dedup(self): # Review Focus 4 + jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE + self.fake.add("X", updated=CHANGED) + self.fake.add("Y") + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.fake.add_history("Y", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.jql_results[jql] = ["X", "Y"] + out = self.refresh({"since": SINCE, "jql": jql}) + self.assertEqual(sorted(c["key"] for c in self.sent()), ["X", "Y"]) + self.assertEqual(out["result"]["history"]["changes"], 2) + + def test_neighbor_changed_included(self): # Review Focus 5 + self.fake.add("X") + self.fake.add("N", in_base=False, updated=CHANGED) + self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "X", "target": "N"}) + self.fake.add_history("N", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.refresh({"since": SINCE}) + self.assertEqual([c["key"] for c in self.sent()], ["N"]) + + def test_jira_500_degrades(self): # «Ошибка Jira при чтении журнала» + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + orig = self.fake.route + + def route(method, path, q, body): + if q.get("expand") == "changelog": + return 500, {"errorMessages": ["сбой"]} + return orig(method, path, q, body) + self.fake.route = route + out = self.refresh({"since": SINCE}) + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["history"]["error"].startswith("HTTP 500")) + self.assertNotIn("retryable", out) + self.assertTrue(self.hub.states) + self.assertTrue(any("журнал" in l for l in self.logs)) + + def test_backfill_400(self): # «Ошибка стартовой загрузки» + jql = "bad jql" + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + orig = self.fake.route + + def route(method, path, q, body): + if q.get("jql") == jql: + return 400, {"errorMessages": ["Значение не существует"]} + return orig(method, path, q, body) + self.fake.route = route + out = self.refresh({"since": SINCE, "jql": jql}) + h = out["result"]["history"] + self.assertEqual(out["status"], "done") + self.assertTrue(h["backfill_error"].startswith("HTTP 400")) + self.assertNotIn("error", h) + self.assertEqual([c["key"] for c in self.sent()], ["X"]) + + def test_incomplete(self): # «Неполный журнал» + self.fake.add("X", updated=CHANGED) + for n in range(250): + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.fake.changelog_cap = 100 + out = self.refresh({"since": SINCE}) + self.assertEqual(out["result"]["history"]["incomplete"], ["X"]) + self.assertEqual(len(self.sent()), 100) + self.assertTrue(any("X: получено 100 из 250" in l for l in self.logs)) + self.assertIn(("GET", "/rest/api/2/issue/X"), self.fake.requests) + + def test_truncated_search_completed_by_issue(self): + self.fake.add("X", updated=CHANGED) + for n in range(150): + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.fake.changelog_cap = 100 + self.fake.issue_changelog_cap = 150 + out = self.refresh({"since": SINCE}) + self.assertEqual(out["result"]["history"], {"issues": 1, "changes": 150, "incomplete": []}) + + def test_custom_field_lookup_once(self): # «Плановая дата без fieldId» + self.fake.add("X", updated=CHANGED) + self.fake.add("Y", updated=CHANGED) + for k in ("X", "Y"): + self.fake.add_history(k, "2026-10-22T12:10:00.000+0300", + [_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom")]) + self.refresh({"since": SINCE}) + self.assertEqual([c["field_id"] for c in self.sent()], ["customfield_10201", "customfield_10201"]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) + + def test_hub_422_degrades(self): # Review Focus 3 + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.hub.reject_history = (422, {"error": "bad_history", "detail": "changes[0].seq"}) + out = self.refresh({"since": SINCE}) + self.assertEqual(out["status"], "done") + self.assertTrue(out["result"]["history"]["error"].startswith("хаб: ")) + self.assertNotIn("retryable", out) + + def test_hub_down_fails(self): + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + self.hub.reject_history = "drop" + out = self.refresh({"since": SINCE}) + self.assertEqual(out["status"], "failed") + self.assertTrue(out["retryable"]) + + def test_batches_of_500(self): + self.fake.add("X", updated=CHANGED) + for n in range(600): + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")]) + out = self.refresh({"since": SINCE}) + self.assertEqual([len(b["changes"]) for b in self.hub.histories], [500, 100]) + self.assertEqual(out["result"]["history"]["changes"], 600) + + def test_no_history_param(self): # «Хаб без журнала» + self.fake.add("X", updated=CHANGED) + out = self.refresh() + self.assertNotIn("history", out["result"]) + self.assertEqual(self.changelog_queries(), []) + + def test_version(self): + self.assertEqual(agent.AGENT_VERSION, 13) + + if __name__ == "__main__": unittest.main() From 05e3f2c1141caa5314867a25dde128cb2bc730a9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:34:43 +0400 Subject: [PATCH 5/7] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?:=2013=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_folder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_folder.py b/tests/test_folder.py index f738461..af7840f 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -183,7 +183,7 @@ class FolderIntegrationTest(unittest.TestCase): return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 12) + self.assertEqual(agent.AGENT_VERSION, 13) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") From faed8b9c7a610376926b2877b727836d4efef452 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:47:11 +0400 Subject: [PATCH 6/7] =?UTF-8?q?=D0=96=D1=83=D1=80=D0=BD=D0=B0=D0=BB:=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9?= =?UTF-8?q?,=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=B5=D0=B8=D1=85=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=D1=85=20=D0=BD=D0=B5=20=D1=82=D0=B5=D1=80=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8E=20(#39,=20I?= =?UTF-8?q?-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 20 +++++++++++--------- tests/test_history.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/agent.py b/src/agent.py index ca5f1db..1fd9da8 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1241,7 +1241,8 @@ class Executor: return got def refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]: - """Журнал после состояния: since — задачи этого refresh с updated не раньше since, jql — стартовая загрузка. + """Журнал после состояния: jql — стартовая загрузка (первой, журнал целиком), since — задачи этого refresh с + updated не раньше since, ещё не прочитанные стартовой. Ответ не 2xx Jira или хаба — текст в error (since, отправка) или backfill_error (jql), refresh остаётся done; обрыв связи — как во всём refresh.""" h = p.get("history") @@ -1263,10 +1264,16 @@ class Executor: for raw in raws: if raw["key"] in read: continue - read.add(raw["key"]) items.extend(self.changelog_items(raw["key"], self.full_changelog(raw, out["incomplete"]), since, field_id_of)) + read.add(raw["key"]) # только прочитанную: упавшую на ней стартовую часть дочитает since + if h.get("jql"): # первой: журнал задач стартовой выборки — целиком (I-1) + try: + collect(self.search_changelog(h["jql"]), None) + except HttpError as e: + out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500]) + log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"]) if h.get("since"): since = _jira_time(h["since"]) try: @@ -1275,8 +1282,9 @@ class Executor: changed = [] for r in fetched: upd = _jira_time(((r.get("fields") or {}).get("updated"))) - if upd is not None and upd >= since and r["key"] not in changed: + if upd is not None and upd >= since and r["key"] not in read: changed.append(r["key"]) + changed = list(dict.fromkeys(changed)) for i in range(0, len(changed), 100): collect(self.search_changelog("key in (%s)" % ",".join(changed[i:i + 100])), since) except HttpError as e: @@ -1285,12 +1293,6 @@ class Executor: except ValueError as e: out["error"] = str(e) log("refresh: %s" % e) - if h.get("jql"): - try: - collect(self.search_changelog(h["jql"]), None) - except HttpError as e: - out["backfill_error"] = "HTTP %d %s" % (e.code, e.text[:500]) - log("refresh: стартовая загрузка журнала не прочитана: %s" % out["backfill_error"]) sent_keys = set() for i in range(0, len(items), self.HISTORY_BATCH): chunk = items[i:i + self.HISTORY_BATCH] diff --git a/tests/test_history.py b/tests/test_history.py index f5b0920..42955e1 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -174,6 +174,37 @@ class RefreshHistoryTest(Base): self.assertEqual(sorted(c["key"] for c in self.sent()), ["X", "Y"]) self.assertEqual(out["result"]["history"]["changes"], 2) + def test_since_and_jql_keeps_old_history(self): # I-1: задача в обеих выборках — журнал целиком + jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c")]) + self.fake.jql_results[jql] = ["X"] + out = self.refresh({"since": SINCE, "jql": jql}) + self.assertEqual(sorted(c["created"][:4] for c in self.sent()), ["2020", "2026"]) + self.assertEqual(out["result"]["history"]["changes"], 2) + self.assertEqual(len(self.changelog_queries()), 1) # X уже прочитана стартовой — второго search нет + + def test_backfill_failure_on_issue_leaves_it_to_since(self): # I-1: упавшая на задаче стартовая часть её не «съедает» + jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE + self.fake.add("X", updated=CHANGED) + self.fake.add_history("X", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")]) + self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c")]) + self.fake.jql_results[jql] = ["X"] + self.fake.changelog_cap = 1 # search обрезает — дочитывание GET /issue отдаёт обе записи + self.fake.issue_changelog_cap = 2 + orig, failed = self.fake.route, [] + + def route(method, path, q, body): + if path == "/rest/api/2/issue/X" and q.get("expand") == "changelog" and not failed: + failed.append(1) + return 500, {"errorMessages": ["сбой"]} + return orig(method, path, q, body) + self.fake.route = route + out = self.refresh({"since": SINCE, "jql": jql}) + self.assertTrue(out["result"]["history"]["backfill_error"].startswith("HTTP 500")) + self.assertEqual([c["created"][:4] for c in self.sent()], ["2026"]) + def test_neighbor_changed_included(self): # Review Focus 5 self.fake.add("X") self.fake.add("N", in_base=False, updated=CHANGED) From 4ed011bcc0b4e8b0d0f570879a31f25459421b64 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:50:41 +0400 Subject: [PATCH 7/7] =?UTF-8?q?=D0=A0=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20v13=20=D0=B2=20design?= =?UTF-8?q?.md=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v13-change-history/design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/agent-v13-change-history/design.md b/openspec/changes/agent-v13-change-history/design.md index 4eb0385..3b01ce3 100644 --- a/openspec/changes/agent-v13-change-history/design.md +++ b/openspec/changes/agent-v13-change-history/design.md @@ -12,3 +12,8 @@ discussion»); здесь — только решения агентской ч | Маркер | Дата | Решение | Обоснование | Цена ошибки | |---|---|---|---|---| +| R-3 | 2026-10-22 | Ошибка `GET /field` или дочитывания `GET /issue` относится к той части журнала, которую она прервала: в регулярной — `history.error`, в стартовой — `backfill_error` | Спека называет только `search` и отправку; ошибка чтения — свойство части, которую она прервала | Стартовая часть, упавшая на `GET /field`, не держит отметку — как задумано для разовой стартовой загрузки | +| R-4 | 2026-10-22 | Ошибка одной части журнала не отменяет другую и отправку уже прочитанного | Меньше потерь; спека требует этого для обратного случая («регулярная часть при этом выполняется») | При `error` отметка не сдвинется, следующий прогон дочитает — дубли хаб отбросит | +| R-5 | 2026-10-22 | Неразбираемый `history.since` — `history.error` с текстом, `refresh` остаётся `done` | Анти-цель «журнал не ломает обновление зеркала» | Нет: хаб шлёт `since` сам в одном формате | +| R-6 | 2026-10-22 | Недоступность хаба только на `/agent/history` в тестах имитирует `FakeHub.reject_history = "drop"` (обрыв соединения без ответа) | Нужен обрыв именно на шаге журнала | Нет (тестовый фейк) | +| R-7 | 2026-10-22 | По I-1 финального ревью: стартовая часть (`history.jql`) выполняется первой и берёт журнал целиком; регулярная не запрашивает уже прочитанные задачи; ключ помечается прочитанным только после разбора его журнала. Хаб: design #19 | Задача из обеих выборок теряла записи старше `since`; упавшая на задаче стартовая часть не должна «съедать» её для регулярной | Нет лишних вызовов Jira; при сбое стартовой на задаче регулярная добирает только записи после `since` |