Агент v13: журнал изменений Jira в refresh (#39) #40

Merged
aep merged 7 commits from feat/39-change-history into main 2026-10-22 10:05:46 +03:00
10 changed files with 694 additions and 7 deletions

View File

@ -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` | | 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/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | | Журнал | `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` | | Самообновление | `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` | | Цикл | `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` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |

View File

@ -174,7 +174,7 @@ python3 agent.py prune
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `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`), тип доски в результате Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами `refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
выкатывается только после v12 (design хаба `projects`, решение 14). выкатывается только после v12 (design хаба `projects`, решение 14).
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.
@ -252,6 +254,10 @@ python3 agent.py prune
`/issue/{key}/comment`. `/issue/{key}/comment`.
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается. задачи (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` на 5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы, `\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
завершающий перевод строки) — изменение. завершающий перевод строки) — изменение.

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

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-22

View File

@ -0,0 +1,19 @@
# 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»); здесь — только решения агентской части, принятые при исполнении.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 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` |

View File

@ -33,7 +33,7 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 12 AGENT_VERSION = 13
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@ -363,6 +363,9 @@ class HubClient(Http):
def known_keys(self) -> List[str]: def known_keys(self) -> List[str]:
return self.call("GET", "/agent/known-keys")["keys"] 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: def file(self, sha256: str) -> dict:
"""Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file.""" """Файл вложения из хранилища хаба: {file, size, content (base64)}; нет файла — HttpError 404 no_file."""
return self.call("GET", "/agent/files/%s" % sha256) return self.call("GET", "/agent/files/%s" % sha256)
@ -526,6 +529,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]: def _date(value) -> Optional[str]:
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню.""" """Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
return (value or "")[:10] or None return (value or "")[:10] or None
@ -983,6 +1007,36 @@ class Executor:
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t}) out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
return out 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 @staticmethod
def comment_state(c: dict) -> dict: def comment_state(c: dict) -> dict:
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira.""" """Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""
@ -1148,6 +1202,9 @@ class Executor:
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
"page": pages, "last_page": i + 100 >= len(issues)}) "page": pages, "last_page": i + 100 >= len(issues)})
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} 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: if missing:
result["missing"] = list(dict.fromkeys(missing))[:50] result["missing"] = list(dict.fromkeys(missing))[:50]
if types: if types:
@ -1155,6 +1212,104 @@ class Executor:
return self._out("done", result) 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]:
"""Журнал после состояния: jql — стартовая загрузка (первой, журнал целиком), since — задачи этого refresh с
updated не раньше since, ещё не прочитанные стартовой.
Ответ не 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
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:
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 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:
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)
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 → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте # Цикл: claim → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -3,6 +3,7 @@ import base64
import hashlib import hashlib
import json import json
import re import re
import socket
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@ -28,6 +29,9 @@ class FakeHub:
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба) self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
self.file_requests = 0 self.file_requests = 0
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб 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() self.lock = threading.Lock()
hub = self hub = self
@ -81,6 +85,10 @@ class FakeHub:
if h.headers.get("Authorization") != "Bearer " + self.token: if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"}) return self._send(h, 401, {"error": "unauthorized"})
path = h.path.split("?")[0] 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: if path == "/agent/claim" and self.claim_wait:
end = time.monotonic() + self.claim_wait end = time.monotonic() + self.claim_wait
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
@ -123,7 +131,14 @@ class FakeHub:
return 200, self.settings return 200, self.settings
if path == "/agent/known-keys": if path == "/agent/known-keys":
return 200, {"keys": list(self.known)} 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": if path == "/agent/state":
self.posts.append(path)
self.states.append(body) self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])} return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/files/([^/]+)$", path) m = re.match(r"^/agent/files/([^/]+)$", path)

View File

@ -87,6 +87,14 @@ class FakeJira:
self.bad_attachment_response = False # ответить на загрузку пустым списком self.bad_attachment_response = False # ответить на загрузку пустым списком
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре) self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira 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() self.lock = threading.Lock()
fake = self fake = self
@ -133,6 +141,19 @@ class FakeJira:
self.issues[key] = base self.issues[key] = base
return 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", def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
updated=None): updated=None):
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else 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 return out
def search_keys(self, jql): 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 "") m = re.search(r"key in \(([^)]*)\)", jql or "")
if m: if m:
keys = [k.strip() for k in m.group(1).split(",")] 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)) start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
page = keys[start:start + maxr] page = keys[start:start + maxr]
fields = (q.get("fields") or "").split(",") if q.get("fields") else [] 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": if p == "/rest/api/2/issue" and method == "POST":
self.seq += 1 self.seq += 1
key = "%s-%d" % (self.project, self.seq) key = "%s-%d" % (self.project, self.seq)
@ -290,7 +318,11 @@ class FakeJira:
if m: if m:
key = m.group(1) key = m.group(1)
if method == "GET": 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": if method == "PUT":
f = self.issues[key] f = self.issues[key]
for k, v in body.get("fields", {}).items(): for k, v in body.get("fields", {}).items():

View File

@ -183,7 +183,7 @@ class FolderIntegrationTest(unittest.TestCase):
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
def test_version(self): def test_version(self):
self.assertEqual(agent.AGENT_VERSION, 12) self.assertEqual(agent.AGENT_VERSION, 13)
def test_runner_through_folder(self): def test_runner_through_folder(self):
self.fake.add("AURORA-1", duedate="2026-10-20") self.fake.add("AURORA-1", duedate="2026-10-20")

317
tests/test_history.py Normal file
View File

@ -0,0 +1,317 @@
"""Журнал изменений 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")))
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_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)
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()