Merge pull request 'Агент v13: журнал изменений Jira в refresh (#39)' (#40) from feat/39-change-history into main

This commit is contained in:
aep 2026-10-22 10:05:44 +03:00
commit 4e07de32ed
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` |
| Папка | `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 — под блокировкой журнала) | файловая система |
| Исполнитель | `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` |

View File

@ -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`); отправляется текст команды как есть. Прочие различия (пробелы,
завершающий перевод строки) — изменение.

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 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)
@ -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]:
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
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})
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."""
@ -1148,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:
@ -1155,6 +1212,104 @@ 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]:
"""Журнал после состояния: 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 → пауза → журнал → выполнение → отчёт до подтверждения; восстановление при старте
# ---------------------------------------------------------------------------

View File

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

View File

@ -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():

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

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