Merge pull request 'feat: ядро моста Jira — агент (bridge-core, #1)' (#2) from feat/1-bridge-core into main

This commit is contained in:
aep 2026-10-12 20:47:35 +03:00
commit 3b9b3b35c2
18 changed files with 5332 additions and 19 deletions

View File

@ -1,15 +1,32 @@
# Архитектура
{{Карта модулей на одну страницу: что где лежит, границы, потоки данных. Читается агентом перед architectural brainstorming. Обновляется вместе со спеками.}}
Агент — один файл `src/agent.py` (Python 3.9, только стандартная библиотека). Тесты в `tests/` работают на фейках
Jira и хаба. Спека: `../jira-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../jira-hub/docs/superpowers/plans/2026-10-11-bridge-core-hub.md`).
## Модули
## Разделы файла `src/agent.py`
| Модуль | Ответственность | Зависит от |
|---|---|---|
| | | |
| Раздел | Классы и функции | Ответственность | Что использует |
|---|---|---|---|
| Настройки | `load_config` | читает `agent.json`, проверяет обязательные поля | — |
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient` | запросы; `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова | `urllib` |
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` | файловая система |
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте | `HubClient`, `JiraClient`, `Journal`, `Executor` |
| CLI | `cmd_run`, `cmd_check`, `main` | команды `run`, `check`, `prune`; обработка сигналов | `Runner`, `Journal` |
## Границы
- Что не должно импортировать что.
- Пароль и токен Jira — только в `agent.json`. В хаб, журнал и логи не попадают.
- Политика темпа не хранится в агенте: зазоры (`pacing`) и `pause_seconds` приходят от хаба. Агент досыпает паузу
до зазора от последнего вызова Jira (`JiraClient.last_call_at`).
- Одна команда в полёте: цикл берёт следующую только после выполнения и подтверждения отчёта.
- Отчёт до подтверждения: `send_result` повторяет каждые 10 с; 404/409/422 от хаба считаются закрытием.
- Журнал пишется трижды на команду вокруг выполнения: `started` — после паузы, перед первым обращением к Jira;
`finished` — после результата; `acked` — после подтверждения хаба. Восстановление опирается только на этот порядок.
- Каждый обработчик `Executor` возвращает `status`/`result`/`error` и счётчики. Исключение, которое не обработано
в обработчике, `Runner.execute` превращает в `failed` и не роняет цикл.
- Тесты не ходят во внешние сервисы; фейки — `tests/fake_jira.py` и `tests/fake_hub.py`.
## Открытые вопросы
-
- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 3).
- Защита `prune` от параллельной записи: блокировки нет (ограничение README, пункт 1).
- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6).

View File

@ -1,28 +1,48 @@
# jira-agent
## Что это
{{ОДНИМ АБЗАЦЕМ: что делает система, для кого, главный сценарий.}}
Клиент моста Jira для виртуалки с доступом к Jira банка. Опрашивает хаб `jira-hub` (Mac mini), выполняет команды
«желаемый результат» по правилу «проверь, потом сделай», ведёт журнал на диске и отдаёт хабу состояние задач.
Главный сценарий: хаб ставит команду (например, создать задачу, перевести статус, перенести в спринт), агент
читает задачу в Jira, записывает только разницу и возвращает результат; хаб синхронизирует зеркало через `refresh`.
Спека: `../jira-hub/openspec/changes/bridge-core/` (`specs/agent/spec.md`, `specs/commands/spec.md`).
## Стек и ограничения
- {{Язык, сборка, тесты — например: Python 3.12, uv, pytest.}}
- {{Жёсткие ограничения среды исполнения — без чего код не должен собираться.}}
- Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей.
- Запрещено: синтаксис 3.10+ и сторонние импорты.
`tests/test_py39.py` ловит синтаксис 3.10+ (match и т. п.) и сторонние импорты; `X | Y` — синтаксис 3.10: в 3.9 аннотации функций вычисляются при импорте, и прогон тестов на /usr/bin/python3 3.9.6 это ловит; аннотации локальных переменных не вычисляются, их и API новее 3.9 проверяет только ревью.
- `agent.json` содержит учётные данные Jira — не коммитить, не отправлять в хаб, не выносить с виртуалки.
Шаблон без секретов — `src/agent.example.json`.
## Архитектура
- `src/core/` (или {{ваш путь}}) — чистая логика без зависимостей от фреймворка/платформы. Покрывается тестами полностью.
- `src/{{adapter}}/` — тонкая обёртка над платформой. Тестируется руками или интеграционно.
- {{Ключевые интерфейсы/порты между ними, если есть.}}
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`),
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
выполнение → отчёт), CLI (`cmd_check`, `cmd_run`, `main`).
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
- Подробнее: `ARCHITECTURE.md`.
## Команды
- `{{npm test / pytest}}` — весь набор тестов. Зелёным считается только весь набор.
- `{{npm run build / ...}}` — сборка.
- `{{npm run dev / ...}}` — локальный запуск.
- `/usr/bin/python3 -m unittest discover -s tests -t .` — весь набор тестов. Зелёным считается только весь набор.
- `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — то же с ошибками на незакрытые ресурсы (так проверяем перед коммитом).
- `/usr/bin/python3 src/agent.py check` — доступность хаба и Jira, без записи в Jira.
- `/usr/bin/python3 src/agent.py` — основной цикл (нужен `agent.json` рядом с `agent.py`).
- `/usr/bin/python3 src/agent.py prune` — очистка журнала (не при работающем агенте).
## Тесты
- Golden-тесты: `tests/fixtures/<name>.*` + эталон `<name>.expected.*`. Внешние сервисы в тестах мокаются.
- {{Где лежит тестовое окружение (vault, БД, песочница).}}
- Тесты в `tests/`, запуск из корня worktree. Внешних сервисов нет: Jira и хаб заменяют фейки `tests/fake_jira.py` и `tests/fake_hub.py`.
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_refresh.py` (`refresh`),
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429).
- Golden-фикстур пока нет: каждый тест строит сценарий в коде.
## Первая подсистема
{{Что делается первым и почему. Тестовая инфраструктура — Task 1 первого плана.}}
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
журнал, восстановление после перезапуска, CLI `run|check|prune`.
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
## OpenSpec и superpowers
- Вход в любую задачу — superpowers:brainstorming. Skills openspec-propose и openspec-explore сами по себе не запускать.

181
README.md Normal file
View File

@ -0,0 +1,181 @@
# jira-agent
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
и возвращает хабу состояние задач. Один файл `src/agent.py`, только стандартная библиотека, Python 3.9.
Спека: `../jira-hub/openspec/changes/bridge-core/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
## Доставка на виртуалку
1. Скопировать `src/agent.py` и `src/agent.example.json` через общую папку в каталог на виртуалке, например `~/jira-agent/`.
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
- `hub_url` — `http://192.168.0.106:8790`;
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
- `jira.base_url` — адрес Jira банка;
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 3 ниже);
- `timeout` — таймаут запроса в секундах (по умолчанию 60);
- `poll_seconds` — пауза между опросами хаба, когда команд нет (по умолчанию 5).
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
Остальные настройки (id полей, типы задач, карта переходов, типы связей, доска, темп) агент получает от хаба при
старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно.
## Проверка
```
python3 agent.py check
```
В норме печатает две строки с префиксом времени `[ЧЧ:ММ:СС]`, например:
```
[10:42:17] хаб: доступен, настройки v3, проект AURORA
[10:42:18] Jira: доступен, пользователь ivanov
```
(`хаб: доступен, настройки v<N>, проект <KEY>` и `Jira: доступен, пользователь <login>`).
Код выхода 0. Если хаб или Jira недоступны, строка содержит `НЕДОСТУПЕН` и текст ошибки, код выхода 1.
Команда ничего не пишет в Jira.
## Запуск
В терминале:
```
python3 agent.py
```
В фоне с логом:
```
nohup python3 -u agent.py > agent.log 2>&1 &
```
Остановка: Ctrl-C или `kill <pid>`. Первый сигнал — агент доделывает текущую команду, отправляет отчёт и выходит.
Повторный сигнал — немедленный выход (код 130). Остановка во время паузы перед командой безопасна: команда уже
записана в журнал как `claimed`, при следующем запуске агент запрашивает её у хаба и выполняет через проверку (без паузы).
## Автозапуск через launchd (опционально)
Пример `~/Library/LaunchAgents/ru.aepif.jira-agent.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>ru.aepif.jira-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/Users/USER/jira-agent/agent.py</string>
</array>
<key>WorkingDirectory</key><string>/Users/USER/jira-agent</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>StandardOutPath</key><string>/Users/USER/jira-agent/agent.log</string>
<key>StandardErrorPath</key><string>/Users/USER/jira-agent/agent.log</string>
</dict>
</plist>
```
Подставить свой путь вместо `USER`. Запуск и остановка:
```
launchctl load ~/Library/LaunchAgents/ru.aepif.jira-agent.plist
launchctl unload ~/Library/LaunchAgents/ru.aepif.jira-agent.plist
```
`WorkingDirectory` нужен, чтобы `agent.json` и `journal.jsonl` лежали рядом с `agent.py`.
## Журнал
`journal.jsonl` лежит рядом с `agent.py`. Это только дозапись: на каждую команду пишутся `claimed` (сразу после получения
из хаба, до паузы), `started` (после паузы, перед обращением к Jira), `finished` (результат и счётчики вызовов) и `acked` (хаб подтвердил приём отчёта).
По журналу агент после перезапуска досылает неподтверждённые отчёты и находит команды, полученные или начатые, но не завершённые.
В журнале нет параметров команд (текстов задач и комментариев): только id команды, статус, `result`, `error` и счётчики
вызовов. Но `result` и `error` могут содержать ключи задач и фрагменты ответов Jira, поэтому журнал лучше не выносить с виртуалки.
Очистка подтверждённых записей старше 30 дней:
```
python3 agent.py prune
```
Не запускать при работающем агенте (ограничение 1 ниже).
## Что делает агент с командами
Типы команд описаны в спеке `commands/spec.md`. Здесь — что агент делает с каждым:
| Тип | Что делает |
|---|---|
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт; возвращает `key` |
| `ensure_fields` | читает задачу и пишет только отличающиеся поля |
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог |
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
- **Паузы.** Хаб выдаёт команду с `pause_seconds` — агент досыпает до зазора от последнего вызова Jira. Внутри команды
с несколькими записями зазор берётся из `pacing` настроек хаба.
- **Одна команда в полёте.** Агент берёт следующую команду, только когда предыдущая выполнена и отчёт подтверждён.
- **Отчёт до подтверждения.** Отчёт повторяется каждые 10 с, пока хаб не подтвердит. Ответы 404/409/422 хаб
выдаёт, когда команда уже отменена или удалена: агент считает её закрытой и не зацикливается.
- **429 и 503.** Агент ждёт `Retry-After` из ответа Jira и повторяет запрос: до 5 запросов, то есть не более 4 ожиданий;
на пятой неудаче — ошибка команды. Если заголовка нет или он в формате HTTP-date (не разбирается), ждёт 60 с, умноженные на номер попытки. Число ответов 429/503 идёт в `rate_limited` отчёта.
- **Обрыв связи с Jira (R-10).** Если Jira оборвала соединение или ответила 5xx, исход записи неизвестен: агент не пишет
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 1) отправляется в каждом `claim`, в каждом отчёте
(`agent_version`) и в `/agent/state`.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему.
## Тесты
```
/usr/bin/python3 -m unittest discover -s tests -t .
```
Тот же Python 3.9.6, что на виртуалке. Внешних сервисов нет: Jira и хаб заменяют фейки
(`tests/fake_jira.py`, `tests/fake_hub.py`). `tests/test_py39.py` проверяет, что `agent.py` разбирается как Python 3.9
и импортирует только стандартную библиотеку.
## Обновление агента
Скопировать новый `agent.py` поверх старого и перезапустить процесс (или `launchctl unload` + `load`).
`agent.json` и `journal.jsonl` не трогать: журнал переживает обновление, а неподтверждённые отчёты досылаются при старте.
## Известные ограничения / эксплуатация
В порядке важности:
1. **`prune` не запускать при работающем агенте.** Блокировки нет. Запись, добавленная агентом во время `prune`,
может пропасть при перезаписи файла. Сначала остановить агента.
2. **Остановка во время паузы или между claim и выполнением (R-9).** Команда остаётся `claimed` в хабе, но в журнале
есть запись `claimed`: при перезапуске агент запрашивает её у хаба и выполняет без паузы. Отмена с force и повтор
больше не нужны. Если журнал потерян, команду придётся отменить с force (`POST /commands/{id}/cancel`,
`{"force": true}`) и повторить (`POST /commands/{id}/retry`).
3. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url`
должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают.
4. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`.
5. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
6. **Сравнение описания в `ensure_fields` точное.** Если Jira нормализует переводы строк (CRLF), будут лишние
записи. Наблюдать на реальной Jira.
7. **429/503 без потолка.** Агент ждёт `Retry-After` в секундах сколько бы он ни был (без верхней границы): не более 4 ожиданий,
на пятой неудаче — ошибка. Длинный `Retry-After` держит очередь. Формат HTTP-date в `Retry-After` не разбирается:
тогда ждётся 60 с × номер попытки. Нецелый `Retry-After` (например, `1.5`) тоже не разбирается: те же 60 с × попытка.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
# bridge-core (агент) — решения
Спека изменения лежит в репозитории jira-hub: `jira-hub/openspec/changes/bridge-core/` (specs/agent, specs/commands, specs/hub-api, design.md). Здесь — только решения, принятые при реализации агента без обсуждения с человеком.
## Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-11 | `tests/__init__.py` добавляет каталог `tests/` в `sys.path` (R-1) | Тесты из плана импортируют `_common`/`fake_jira` по простому имени, при `discover -s tests -t .` иначе ImportError | Мелкая: переписать импорты в тестах на относительные |
| 2 | 2026-10-11 | Потолок на `Retry-After` не вводим (R-2) | Спека: «ждать `Retry-After`»; потолок тихо нарушает требование сервера | Агент может уснуть надолго при аномальном заголовке; лечится потолком позже |
| 3 | 2026-10-11 | Журнал: не лечим права существующего файла и не блокируем prune/append flock'ом; запрет `prune` при работающем агенте — в README (R-3) | Журнал создаётся этим же кодом (0600); `prune` — ручная команда раз в месяц | Редкая потеря записи журнала при гонке prune/run; лечится flock |
| 4 | 2026-10-11 | Minor-замечания ревью Task 3 (T3.1, T3.3–T3.6, T3.8: пагинация поиска дубля, флаги existed/created, режим epic_link, префикс маркера, общий except в run, тесты) чиним одной финальной волной, а не по месту (R-4) | Не блокируют Task 4–5; одна волна дешевле | Task 4–5 унаследуют общий `run`; ошибки будут чиниться после |
| 5 | 2026-10-11 | Неизвестный `link_type` берётся как сырое имя типа Jira (fallback), без `failed` (R-5) | Так же делает хаб в `check_confirmed`; допускает ручной ввод имён Jira | Опечатка в типе даст HTTP-ошибку Jira вместо понятной ошибки агента |
| 6 | 2026-10-11 | Спринт в `refresh` берётся из Agile API по активным/будущим спринтам доски, а не из поля `search`; закрытые спринты дают `sprint = null` (R-6) | В настройках хаба нет id поля спринта (`field_ids` его не содержит); план Task 5 выбрал Agile | Исторические имена спринтов затрутся при полном refresh; лечится полем спринта в настройках |
| 7 | 2026-10-11 | Бисекция чанка `key in (...)` при 400 принята без лимита вызовов (до ~2·m·log2(n)); если не найден ни один ключ — ошибка, а не `missing` (R-7) | Иначе refresh с удалённой задачей падает целиком; лимит вызовов усложняет без выгоды при редких удалениях | При массовом удалении задач — всплеск запросов и риск 429 (агент их переживает) |
| 8 | 2026-10-11 | Общий `except Exception` стоит в `Runner.execute` (не в `Executor.run`); minor-находки T3.x/T5.x/T6.x чиним одной финальной волной, включая ложный `failed` для `ensure_issue` при ConnectionError из Jira (R-8) | Исключение вне HttpError/ConnectionError не должно ронять цикл и вызывать зацикленное восстановление; одна волна дешевле | Ложный `failed` у `ensure_issue` оставит временный ключ неразрешённым, а задача в Jira уже создана |
| 9 | 2026-10-12 | В журнал пишется запись `claimed` сразу после `claim`, до паузы; `unfinished_started`, `recover`, `prune` считают её открывающей; `started` по-прежнему после паузы (R-9) | Иначе остановка агента в паузе/claim оставляет команду `claimed` в хабе навсегда (хаб не переиздаёт), а лечение cancel+retry не восстанавливает зависимые команды пакета | Формат журнала шире, чем в спеке (лишний `kind`); старые журналы совместимы |
| 10 | 2026-10-12 | `Runner.execute` повторяет команду до 3 раз через проверку при ConnectionError/HTTP 5xx, не записывая `finished`; затем `failed` «результат неизвестен, повтор безопасен» (R-10) | Неоднозначный исход записи в Jira (обрыв после POST) нельзя отдавать как `failed`: для `ensure_issue` хаб пропустит зависимые команды; все команды идемпотентны | Лишние задержки при недоступной Jira |
| 11 | 2026-10-12 | Запрошенное, но не настроенное поле (epic_key, planned_start/end) даёт `failed`, а не молчаливый `done` (R-11) | Ложный успех хаб запишет в зеркало как выполненный | Команды падают, пока хаб не настроен |
| 12 | 2026-10-12 | `agent_version` добавляется в отчёт и в `/agent/state` (R-12) | Спека: версия «в каждом отчёте»; хаб лишние поля игнорирует | Нет |
| 13 | 2026-10-12 | F-1 (повтор по R-10 не ждёт 30 с, если Jira недоступна с первого вызова и `last_call_at` старый) сначала отложен контроллером (вторая волна фиксов не предусмотрена), затем исправлен по команде владельца: ожидание перед повтором — фиксированное `max(pause, 30)` от момента отказа (`Runner.wait_seconds`), не от последнего успешного вызова Jira | Обрыв соединения не обновляет `last_call_at`, поэтому пауза «от последнего вызова» при старом значении была нулевой и три повтора шли подряд | Повтор ждёт полные 30 с даже если с последнего вызова Jira прошло больше; лишнее ожидание не дольше 30 с на попытку |

12
src/agent.example.json Normal file
View File

@ -0,0 +1,12 @@
{
"hub_url": "http://192.168.0.106:8790",
"hub_token": "агентский токен из `jira-hub tokens`",
"jira": {
"base_url": "https://sdlc.go.rshbank.ru/jira",
"auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""}
},
"verify_ssl": true,
"ca_bundle": null,
"timeout": 60,
"poll_seconds": 5
}

1061
src/agent.py Normal file

File diff suppressed because it is too large Load Diff

7
tests/__init__.py Normal file
View File

@ -0,0 +1,7 @@
# Тесты импортируют соседние модули (_common, fake_jira) по простому имени — добавляем каталог в путь.
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
if _HERE not in sys.path:
sys.path.insert(0, _HERE)

46
tests/_common.py Normal file
View File

@ -0,0 +1,46 @@
"""Общее для тестов: путь к src, настройки хаба как их отдаёт /agent/settings, конфиг агента под фейковый Jira."""
import os
import sys
SRC = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src")
if SRC not in sys.path:
sys.path.insert(0, SRC)
import agent # noqa: E402
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
"ensure_sprint": 0, "refresh": 0}
SETTINGS = {
"base_jql": "project = AURORA AND labels = AWPCA",
"project_key": "AURORA",
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}},
"epic_name_field_id": "customfield_15304",
"field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
"planned_start": "customfield_10801", "planned_end": "customfield_10802"},
"epic_link": {"mode": "field", "field_id": "customfield_15302"},
"priority_map": {"Наивысший": "Критичный", "Высокий": "Высокий", "Средний": "Средний", "Низкий": "Низкий"},
"priority_value_key": "name",
"labels": ["AWPCA"],
"status_labels": {},
"kind_labels": {},
"comment_marker": "Актуализация 10.10.2026",
"transitions_map": {},
"link_types": {"блокирует начало": "ASUPR_Блокер_Начала", "связанные": "ASUPR_Связанные"},
"board_id": 4216,
"pacing": dict(ZERO_PACING),
"min_agent_version": 1,
"settings_version": 1,
}
def jira_cfg(fake, **over):
cfg = {"hub_url": "http://127.0.0.1:1", "hub_token": "agent-token",
"jira": {"base_url": fake.base_url, "auth": {"type": "basic", "user": "me", "password": "secret"}},
"verify_ssl": True, "ca_bundle": None, "timeout": 5, "poll_seconds": 0}
cfg.update(over)
return cfg
def no_sleep(_seconds):
pass

110
tests/fake_hub.py Normal file
View File

@ -0,0 +1,110 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import json
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class FakeHub:
token = "agent-token"
def __init__(self, settings):
self.settings = dict(settings)
self.queue = []
self.commands = {}
self.results = {}
self.states = []
self.known = []
self.claims = 0
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.lock = threading.Lock()
hub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
hub.handle(self, "GET")
def do_POST(self):
hub.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def enqueue(self, ctype, params, pause=0):
cid = len(self.commands) + 1
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
self.queue.append(cid)
return cid
def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"})
with self.lock:
code, payload = self.route(method, h.path.split("?")[0], body)
self._send(h, code, payload)
def _send(self, h, code, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
h.send_response(code)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(data)))
h.end_headers()
h.wfile.write(data)
def route(self, method, path, body):
if path == "/agent/claim":
self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"]}
if path == "/agent/settings":
return 200, self.settings
if path == "/agent/known-keys":
return 200, {"keys": list(self.known)}
if path == "/agent/state":
self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/commands/(\d+)$", path)
if m:
c = self.commands.get(int(m.group(1)))
return (200, c) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m:
cid = int(m.group(1))
if self.fail_results > 0:
self.fail_results -= 1
return 503, {"error": "busy"}
if cid in self.reject_results:
return self.reject_results[cid], {"error": "rejected"}
if cid not in self.commands:
return 404, {"error": "not_found"}
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
self.results[cid] = body
self.commands[cid]["status"] = body.get("status")
if self.in_flight == cid:
self.in_flight = None
return 200, self.commands[cid]
return 404, {"error": "not_found", "detail": path}

258
tests/fake_jira.py Normal file
View File

@ -0,0 +1,258 @@
"""Фейковый Jira Server для тестов агента: состояние в памяти, http.server в потоке,
только маршруты, которые использует агент. Семантика связей — как у настоящего Jira Server."""
import json
import re
import socket
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
WORKFLOW = { # статус → [(имя перехода, целевой статус)]
"Ожидание разработки": [("Взять в разработку", "Разработка"), ("Ожидает анализа", "Ожидает анализа"), ("Снято", "Снято")],
"Разработка": [("Малое исправление", "Готово к тестированию"), ("Готово", "Готово"), ("Снято", "Снято")],
"Ожидает анализа": [("Анализ", "Анализ"), ("Снято", "Снято")],
"Анализ": [("Готово", "Готово")],
"Готово к тестированию": [("Тестирование", "Тестирование")],
"Готово": [],
"Снято": [],
}
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
{"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}]
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
class FakeJira:
def __init__(self, project="AURORA"):
self.project = project
self.issues = {}
self.comments = {}
self.links = []
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
self.id_seq = 10000
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
self.requests = [] # [(метод, путь)]
self.queries = [] # параметры запросов (q) — для проверки JQL
self.drop_next_post_issue = False # выполнить создание задачи и оборвать соединение без ответа
self.lock = threading.Lock()
fake = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
fake.handle(self, "GET")
def do_POST(self):
fake.handle(self, "POST")
def do_PUT(self):
fake.handle(self, "PUT")
def do_DELETE(self):
fake.handle(self, "DELETE")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def add(self, key, in_base=True, **fields):
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
"issuetype": "Задача", "created": "2026-10-01T10:00:00.000+0300", "updated": "2026-10-01T10:00:00.000+0300",
"sprint": None}
self.id_seq += 1
base["id"] = str(self.id_seq)
base.update({c: None for c in CUSTOM})
base.update(fields)
self.issues[key] = base
return base
def writes(self):
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
# --- представление ---------------------------------------------------------
def _user(self, name):
return {"name": name, "displayName": name} if name else None
def issue_json(self, key):
f = self.issues[key]
fields = {"summary": f["summary"], "description": f["description"], "status": {"name": f["status"]},
"priority": {"name": f["priority"], "id": int(f["priority"]) if str(f["priority"]).isdigit() else f["priority"]}, "assignee": self._user(f["assignee"]), "reporter": self._user(f["reporter"]),
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
fields.update({c: f.get(c) for c in CUSTOM})
return {"key": key, "id": f["id"], "fields": fields}
def links_of(self, key):
out = []
for l in self.links:
if l["source"] == key:
out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "outwardIssue": {"key": l["target"]}})
elif l["target"] == key:
out.append({"id": str(l["id"]), "type": {"name": l["type"]}, "inwardIssue": {"key": l["source"]}})
return out
def search_keys(self, jql):
m = re.search(r"key in \(([^)]*)\)", jql or "")
if m:
keys = [k.strip() for k in m.group(1).split(",")]
return [k for k in keys if k in self.issues]
if "reporter = currentUser()" in (jql or ""):
keys = [k for k, f in self.issues.items() if f["reporter"] == "me"]
return keys[::-1] if "ORDER BY created DESC" in jql else keys
return [k for k, f in self.issues.items() if "AWPCA" in f["labels"]]
# --- маршрутизация ----------------------------------------------------------
def handle(self, h, method):
url = urlparse(h.path)
q = {k: v[0] for k, v in parse_qs(url.query).items()}
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
with self.lock:
self.requests.append((method, url.path))
self.queries.append(q)
if self.fail_next:
code, headers = self.fail_next.pop(0)
return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers)
try:
code, payload = self.route(method, url.path, q, body)
except KeyError as e:
code, payload = 404, {"errorMessages": ["Issue Does Not Exist: %s" % e]}
except Exception as e: # некорректный запрос к фейку
code, payload = 400, {"errorMessages": ["фейк: %s: %s" % (type(e).__name__, e)]}
if self.drop_next_post_issue and method == "POST" and url.path == "/rest/api/2/issue":
self.drop_next_post_issue = False
h.close_connection = True
h.connection.shutdown(socket.SHUT_RDWR) # задача создана, ответа клиент не получит
return
self._send(h, code, payload)
def _send(self, h, code, payload, headers=None):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8") if payload is not None else b""
h.send_response(code)
for k, v in (headers or {}).items():
h.send_header(k, v)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(data)))
h.end_headers()
h.wfile.write(data)
def route(self, method, path, q, body):
p = path
if p == "/rest/api/2/myself":
return 200, {"name": "me", "displayName": "Me"}
if p == "/rest/api/2/search":
m = re.search(r"key in \(([^)]*)\)", q.get("jql") or "")
if m and any(k.strip() not in self.issues for k in m.group(1).split(",")): # как Jira Server
return 400, {"errorMessages": ["Задача не существует или у вас нет прав на её просмотр."]}
keys = self.search_keys(q.get("jql"))
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
page = keys[start:start + maxr]
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": [self.issue_json(k) for k in page]}
if p == "/rest/api/2/issue" and method == "POST":
self.seq += 1
key = "%s-%d" % (self.project, self.seq)
f = body["fields"]
self.add(key, summary=f.get("summary"), description=f.get("description"),
priority=(f.get("priority") or {}).get("name", "Средний"), duedate=f.get("duedate"),
labels=list(f.get("labels") or []), issuetype=self._type_name(f.get("issuetype")),
reporter="me", created="2026-10-11T12:00:00.000+0300",
**{c: f.get(c) for c in CUSTOM})
return 201, {"id": "1", "key": key}
if p == "/rest/api/2/issueLinkType":
return 200, {"issueLinkTypes": LINK_TYPES}
if p == "/rest/api/2/issueLink" and method == "POST":
self.link_seq += 1
self.links.append({"id": self.link_seq, "type": body["type"]["name"],
"source": body["inwardIssue"]["key"], "target": body["outwardIssue"]["key"]})
return 201, {}
m = re.match(r"^/rest/api/2/issueLink/(\d+)$", p)
if m and method == "DELETE":
if not any(str(l["id"]) == m.group(1) for l in self.links):
return 404, {"errorMessages": ["Issue link does not exist: %s" % m.group(1)]}
self.links = [l for l in self.links if str(l["id"]) != m.group(1)]
return 204, None
m = re.match(r"^/rest/api/2/issue/([^/]+)$", p)
if m:
key = m.group(1)
if method == "GET":
return 200, self.issue_json(key)
if method == "PUT":
f = self.issues[key]
for k, v in body.get("fields", {}).items():
if k in ("priority", "assignee"):
f[k] = (v or {}).get("name")
elif k == "labels":
f[k] = list(v)
else:
f[k] = v
f["updated"] = "2026-10-11T13:00:00.000+0300"
return 204, None
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p)
if m:
key = m.group(1)
self.issues[key]
lst = self.comments.setdefault(key, [])
if method == "GET":
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 1000))
return 200, {"startAt": start, "maxResults": maxr, "total": len(lst), "comments": lst[start:start + maxr]}
self.comment_seq += 1
lst.append({"id": str(self.comment_seq), "body": body["body"]})
return 201, lst[-1]
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p)
if m and method == "PUT":
for c in self.comments.get(m.group(1), []):
if c["id"] == m.group(2):
c["body"] = body["body"]
return 200, {}
m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p)
if m:
key = m.group(1)
trs = WORKFLOW.get(self.issues[key]["status"], [])
if method == "GET":
return 200, {"transitions": [{"id": str(i + 1), "name": n, "to": {"name": to}} for i, (n, to) in enumerate(trs)]}
tid = int(body["transition"]["id"]) - 1
self.issues[key]["status"] = trs[tid][1]
return 204, None
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
if m:
states = (q.get("state") or "active,future").split(",")
vals = [s for s in self.sprints if s["state"] in states]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)}
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)
if m:
sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))
if method == "GET":
keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
"issues": [{"key": k} for k in keys[start:start + maxr]]}
for k in body["issues"]:
self.issues[k]["sprint"] = sp["name"]
return 204, None
if p == "/rest/agile/1.0/backlog/issue" and method == "POST":
for k in body["issues"]:
self.issues[k]["sprint"] = None
return 204, None
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p)
if m:
name = self.issues[m.group(1)]["sprint"]
sp = next((s for s in self.sprints if s["name"] == name), None)
return 200, {"key": m.group(1), "fields": {"sprint": sp}}
return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]}
def _type_name(self, ref):
ref = ref or {}
if ref.get("name"):
return ref["name"]
return {"10100": "Задача", "12300": "Фича"}.get(str(ref.get("id")), "Задача")

204
tests/test_clients.py Normal file
View File

@ -0,0 +1,204 @@
import unittest
from _common import agent, jira_cfg, no_sleep
from fake_jira import FakeJira
class ClientsTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.jira = agent.JiraClient(jira_cfg(self.fake))
self.jira.sleep = no_sleep
def tearDown(self):
self.fake.stop()
def test_get_and_counters(self):
self.fake.add("AURORA-1")
data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary")
self.assertEqual(data["fields"]["summary"], "Тема AURORA-1")
self.assertEqual(self.jira.calls, 1)
self.assertIsNotNone(self.jira.last_call_at)
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
def test_retry_on_429_with_retry_after(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"}))
self.fake.fail_next.append((503, {}))
data = self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertEqual(data["key"], "AURORA-1")
self.assertEqual(self.jira.rate_limited, 2)
self.assertEqual(self.jira.calls, 3) # считаются все полученные ответы, включая 429/503
def test_http_error_not_retried(self):
with self.assertRaises(agent.HttpError) as cm:
self.jira.get("/rest/api/2/issue/NOPE-1")
self.assertEqual(cm.exception.code, 404)
self.assertIn("Does Not Exist", cm.exception.text)
self.assertEqual(self.jira.calls, 1)
self.assertIsNotNone(self.jira.last_call_at)
def test_gives_up_after_five(self):
self.fake.add("AURORA-1")
self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5)
with self.assertRaises(agent.HttpError):
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertEqual(self.jira.rate_limited, 5)
self.assertEqual(self.jira.calls, 5)
def test_basic_auth_header_and_no_secret_in_repr(self):
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
self.assertNotIn("secret", repr(self.jira))
def test_retry_after_lowercase_header(self):
waits = []
self.jira.sleep = waits.append
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"retry-after": "7"}))
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertEqual(waits, [7])
def test_retry_after_not_a_number(self):
waits = []
self.jira.sleep = waits.append
self.fake.add("AURORA-1")
self.fake.fail_next.append((503, {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}))
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertEqual(waits, [60])
def test_retry_after_negative(self):
waits = []
self.jira.sleep = waits.append
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "-5"}))
self.jira.get("/rest/api/2/issue/AURORA-1")
self.assertEqual(waits, [0])
def test_error_body_read_failure_is_connection_error(self):
import io
import urllib.error
from unittest import mock
class Broken(io.BytesIO):
def read(self, *a):
raise TimeoutError("timed out")
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
with mock.patch("urllib.request.urlopen", side_effect=err):
with self.assertRaises(ConnectionError):
self.jira.get("/x")
self.assertEqual(self.jira.calls, 1)
def test_incomplete_read_is_connection_error(self):
import http.client
from unittest import mock
with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)):
with self.assertRaises(ConnectionError):
self.jira.get("/x")
def test_incomplete_read_inside_response_body(self):
import socket
import threading
srv = socket.socket()
srv.bind(("127.0.0.1", 0))
srv.listen(1)
def serve():
c, _ = srv.accept()
c.recv(4096)
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":")
c.close()
t = threading.Thread(target=serve, daemon=True)
t.start()
cfg = jira_cfg(self.fake)
cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.getsockname()[1]
try:
with self.assertRaises(ConnectionError):
agent.JiraClient(cfg).get("/x")
finally:
t.join(2)
srv.close()
def test_bearer_header(self):
cfg = jira_cfg(self.fake)
cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"}
self.assertEqual(agent.JiraClient(cfg).headers["Authorization"], "Bearer tok123")
def test_unreachable_jira_raises_connection_error(self):
cfg = jira_cfg(self.fake)
cfg["jira"]["base_url"] = "http://127.0.0.1:1"
with self.assertRaises(ConnectionError):
agent.JiraClient(cfg).get("/rest/api/2/myself")
def test_non_json_200_becomes_http_error(self):
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = b"<html>\xff\xfe not json"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = HTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
try:
cfg = jira_cfg(self.fake)
cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.server_port
jira = agent.JiraClient(cfg)
with self.assertRaises(agent.HttpError) as cm:
jira.get("/x")
self.assertEqual(cm.exception.code, 200)
self.assertEqual(jira.calls, 1)
finally:
srv.shutdown()
srv.server_close()
def test_secret_not_in_log_output(self):
import io
from contextlib import redirect_stdout
buf = io.StringIO()
self.fake.add("AURORA-1")
self.fake.fail_next.append((429, {"Retry-After": "0"}))
with redirect_stdout(buf):
self.jira.get("/rest/api/2/issue/AURORA-1")
agent.log("проверка")
out = buf.getvalue()
self.assertNotIn("secret", out)
self.assertNotIn(self.jira.headers["Authorization"], out)
def test_load_config_requires_fields(self):
import json
import os
import tempfile
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({"hub_url": "http://x", "hub_token": "t"}, f)
with self.assertRaises(SystemExit):
agent.load_config(path)
with open(path, "w", encoding="utf-8") as f:
json.dump(jira_cfg(self.fake), f)
cfg = agent.load_config(path)
self.assertEqual(cfg["poll_seconds"], 0)
def test_check_command(self):
import json
import os
import tempfile
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "agent.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(jira_cfg(self.fake), f)
# хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1
self.assertEqual(agent.main(["--config", path, "check"]), 1)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,202 @@
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira
class ExecutorFieldsTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.jira = agent.JiraClient(jira_cfg(self.fake))
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS))
self.ex.sleep = no_sleep
def tearDown(self):
self.fake.stop()
def run_cmd(self, ctype, **params):
return self.ex.run({"id": 1, "type": ctype, "params": params})
def test_ensure_issue_creates_then_finds(self):
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст",
priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"],
epic_key="AURORA-1350", assignee="ivanov")
self.assertEqual(out["status"], "done")
key = out["result"]["key"]
self.assertTrue(out["result"]["created"])
f = self.fake.issues[key]
self.assertEqual(f["priority"], "Высокий")
self.assertEqual(f["customfield_10801"], "2026-10-20")
self.assertEqual(f["customfield_15302"], "AURORA-1350")
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
self.assertEqual(out["jira_calls"], 2)
again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача")
self.assertEqual(again["status"], "done")
self.assertEqual(again["result"], {"key": key, "existed": True})
self.assertEqual(len(self.fake.issues), 1)
self.assertEqual(again["jira_calls"], 1)
def test_ensure_issue_epic_sets_epic_name(self):
out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка")
f = self.fake.issues[out["result"]["key"]]
self.assertEqual(f["issuetype"], "Фича")
self.assertEqual(f["customfield_15304"], "Приёмка")
def test_ensure_issue_priority_mapped(self):
out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший")
self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный")
def test_ensure_fields_writes_only_diff(self):
self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний")
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний",
labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov",
planned_end="2026-10-23", epic_key="AURORA-1350")
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"])
f = self.fake.issues["AURORA-1"]
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
self.assertEqual(f["assignee"], "ivanov")
self.assertEqual(f["customfield_10802"], "2026-10-23")
self.assertEqual(len(self.fake.writes()), 1)
again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov")
self.assertEqual(again["result"], {"changed": []})
self.assertEqual(len(self.fake.writes()), 1)
def test_fields_description_exact_compare(self):
text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать"
self.fake.add("AURORA-1", description=text)
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text)
self.assertEqual(out["result"]["changed"], [])
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n")
self.assertEqual(out["result"]["changed"], ["description"])
def test_fields_date_with_time_from_jira(self):
self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300")
out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23")
self.assertEqual(out["result"]["changed"], [])
def test_fields_clear_assignee_and_epic(self):
self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350")
out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None)
self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"])
self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"])
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"])
def test_fields_missing_issue_is_failed(self):
out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23")
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 404", out["error"])
def test_ensure_comment_upsert(self):
self.fake.add("AURORA-1")
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
self.assertEqual(out["status"], "done")
self.assertTrue(out["result"]["created"])
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список")
same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
self.assertEqual(same["result"]["changed"], False)
upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё")
self.assertEqual(upd["result"]["changed"], True)
self.assertEqual(len(self.fake.comments["AURORA-1"]), 1)
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё")
def test_unknown_type(self):
self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed")
# --- ensure_issue: поиск дубля -----------------------------------------------
def test_ensure_issue_finds_on_later_page(self):
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача")
for i in range(250):
self.fake.add("AURORA-%d" % (600 + i), reporter="me", summary="Другая %d" % i)
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
self.assertEqual(out["result"], {"key": "AURORA-500", "existed": True})
searches = [q for q in self.fake.queries if "jql" in q]
self.assertEqual(len(searches), 2) # вторая страница
self.assertIn("reporter = currentUser()", searches[0]["jql"])
self.assertIn("created >= -7d", searches[0]["jql"])
self.assertIn("ORDER BY created DESC", searches[0]["jql"])
self.assertEqual(searches[0]["fields"], "summary")
self.assertEqual(self.fake.writes(), [])
def test_ensure_issue_substring_is_not_duplicate_and_strip(self):
self.fake.add("AURORA-500", reporter="me", summary="Нужная задача (старая)")
self.fake.add("AURORA-501", reporter="me", summary=" Точная тема \n")
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="Нужная задача")
self.assertTrue(out["result"]["created"])
out = self.run_cmd("ensure_issue", temp_key="n:2", issue_type="task", summary="Точная тема")
self.assertEqual(out["result"], {"key": "AURORA-501", "existed": True})
def test_http_error_becomes_failed(self):
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_issue", temp_key="n:1", issue_type="task", summary="x")
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.fake.add("AURORA-1")
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="М", body="текст")
self.assertEqual(out["status"], "failed")
# --- настройки (R-11) --------------------------------------------------------
def with_settings(self, **over):
s = dict(SETTINGS)
s.update(over)
self.ex = agent.Executor(self.jira, s)
self.ex.sleep = no_sleep
def test_epic_key_without_field_id_fails(self):
self.fake.add("AURORA-1")
self.with_settings(epic_link={"mode": "field"})
for ctype, params in (("ensure_issue", dict(temp_key="n", issue_type="task", summary="Э", epic_key="AURORA-9")),
("ensure_fields", dict(key="AURORA-1", epic_key="AURORA-9"))):
out = self.run_cmd(ctype, **params)
self.assertEqual(out["status"], "failed")
self.assertIn("epic_link.field_id", out["error"])
self.assertEqual(self.fake.writes(), [])
def test_epic_key_with_link_mode_fails_same_in_both(self):
self.fake.add("AURORA-1")
self.with_settings(epic_link={"mode": "link", "field_id": "customfield_15302"})
a = self.run_cmd("ensure_issue", temp_key="n", issue_type="task", summary="Э2", epic_key="AURORA-9")
b = self.run_cmd("ensure_fields", key="AURORA-1", epic_key="AURORA-9")
self.assertEqual((a["status"], b["status"]), ("failed", "failed"))
self.assertIn("epic_link.mode", a["error"])
self.assertIn("epic_link.mode", b["error"])
def test_planned_without_field_id_fails(self):
self.fake.add("AURORA-1")
ids = dict(SETTINGS["field_ids"])
del ids["planned_start"]
self.with_settings(field_ids=ids)
a = self.run_cmd("ensure_issue", temp_key="n", issue_type="task", summary="П", planned_start="2026-10-20")
b = self.run_cmd("ensure_fields", key="AURORA-1", planned_start="2026-10-20")
for out in (a, b):
self.assertEqual(out["status"], "failed")
self.assertIn("field_ids.planned_start", out["error"])
self.assertEqual(self.fake.writes(), [])
# --- комментарии -------------------------------------------------------------
def test_comment_marker_only_at_start(self):
self.fake.add("AURORA-1")
self.fake.comments["AURORA-1"] = [{"id": "1", "body": "Обсуждали, см. Актуализация 10.10.2026 в письме"}]
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="новое")
self.assertTrue(out["result"]["created"]) # маркер в середине чужого комментария — не наш
again = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="новое")
self.assertEqual(again["result"]["changed"], False)
def test_comment_found_on_later_page(self):
self.fake.add("AURORA-1")
self.fake.comments["AURORA-1"] = [{"id": str(i), "body": "чужой %d" % i} for i in range(1, 121)]
self.fake.comments["AURORA-1"].append({"id": "999", "body": "*Маркер.* старое"})
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Маркер", body="новое")
self.assertEqual(out["result"], {"comment_id": "999", "changed": True})
def test_priority_compare_by_id_as_string(self):
self.fake.add("AURORA-1", priority="3")
self.with_settings(priority_value_key="id", priority_map={})
out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3")
self.assertEqual(out["result"], {"changed": []})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,194 @@
import unittest
from unittest import mock
import fake_jira
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_jira import FakeJira
class ExecutorLinksTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.jira = agent.JiraClient(jira_cfg(self.fake))
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS))
self.gaps = []
self.ex.sleep = self.gaps.append
self.fake.add("AURORA-1")
self.fake.add("AURORA-2")
def tearDown(self):
self.fake.stop()
def run_cmd(self, ctype, **params):
return self.ex.run({"id": 1, "type": ctype, "params": params})
# --- статус ---
def test_status_path(self):
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2})
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(again["result"]["steps"], 0)
self.assertEqual(again["jira_calls"], 1)
def test_status_resumes_from_middle(self):
self.fake.issues["AURORA-1"]["status"] = "Разработка"
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"])
self.assertEqual(out["result"]["steps"], 1)
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")
def test_status_single_and_by_transition_name(self):
# имя перехода принято как шаг, но в result.to — реальный статус после перехода
out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
self.assertEqual(out["result"]["to"], "Разработка")
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка")
def test_status_ambiguous_prefers_target_status(self):
# переход «Готово» ведёт в «Анализ», переход «Старт» ведёт в статус «Готово»: выбирается по целевому статусу
with mock.patch.dict(fake_jira.WORKFLOW, {"Ожидание разработки": [("Готово", "Анализ"), ("Старт", "Готово")]}):
out = self.run_cmd("ensure_status", key="AURORA-1", target="Готово")
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 1})
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
def test_status_no_transition_is_failed(self):
out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование")
self.assertEqual(out["status"], "failed")
self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"])
self.assertIn("Взять в разработку→Разработка", out["error"])
self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})
def test_status_bad_target(self):
cases = (([], "пустой путь"), ("", "пустой путь"), ([""], "пустой путь"),
(5, "target должен быть"), (["Готово", 1], "target должен быть"), (None, "target должен быть"))
for bad, msg in cases:
out = self.run_cmd("ensure_status", key="AURORA-1", target=bad)
self.assertEqual(out["status"], "failed", bad)
self.assertIn(msg, out["error"])
self.assertEqual(self.fake.writes(), [])
def test_status_http_error_is_failed(self):
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
def test_status_gap_between_steps(self):
self.ex.s["pacing"] = {"ensure_status": 7}
self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
self.assertEqual(self.gaps, [7])
# --- связи ---
def test_link_create_and_idempotent(self):
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["result"], {"created": True, "removed": []})
link = self.fake.links[0]
self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала"))
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(again["result"], {"existed": True, "removed": []})
self.assertEqual(len(self.fake.links), 1)
def test_link_symmetric_any_direction(self):
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["result"], {"existed": True, "removed": []})
def test_link_conflict_without_replace(self):
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["status"], "failed")
self.assertIn("обратное направление", out["error"])
self.assertEqual(len(self.fake.links), 1)
def test_link_replace_reverse_and_other_type(self):
self.ex.s["pacing"] = {"ensure_link": 3}
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True)
self.assertEqual(out["status"], "done")
self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"])
self.assertTrue(out["result"]["created"])
self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links],
[("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")])
self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST
self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"])
def test_link_unmanaged_type_ignored(self):
self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"})
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["result"], {"created": True, "removed": []})
self.assertEqual(len(self.fake.links), 2)
def test_link_unknown_type_sent_raw(self):
# намеренный fallback (рулинг R-5): неизвестное имя типа уходит в Jira как есть
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="Duplicate")
self.assertEqual(out["result"], {"created": True, "removed": []})
self.assertEqual(self.fake.links[-1]["type"], "Duplicate")
def test_link_http_error_is_failed(self):
self.ex.symmetric_types() # тип уже известен, падает чтение задачи
self.fake.fail_next.append((400, {}))
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertEqual(self.fake.links, [])
def test_symmetric_not_cached_on_error(self):
self.fake.fail_next.append((400, {})) # падает чтение типов связей
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(out["status"], "failed")
self.assertIsNone(self.ex._symmetric) # пустой набор в кэш не попал
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
self.assertEqual(again["result"], {"existed": True, "removed": []})
# --- спринты ---
def test_sprint_move_and_idempotent(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None})
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
self.assertEqual(again["jira_calls"], 1)
def test_sprint_pagination(self):
for i in range(3, 58): # 57 спринтов: доска отдаёт по 50, нужны две страницы
self.fake.sprints.append({"id": i, "name": "Спринт %d" % i, "state": "future"})
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 57")
self.assertEqual(out["status"], "done")
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 57")
pages = [r for r in self.fake.requests if r[1].endswith("/board/4216/sprint")]
self.assertEqual(len(pages), 2)
def test_sprint_backlog(self):
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False})
def test_sprint_backlog_without_board(self):
self.ex.s["board_id"] = None
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
def test_sprint_unknown_name(self):
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
self.assertEqual(out["status"], "failed")
self.assertIn("Спринт 9", out["error"])
self.assertIn("Спринт 2", out["error"])
def test_sprint_without_board(self):
self.ex.s["board_id"] = None
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
self.assertEqual(out["status"], "failed")
self.assertIn("board_id", out["error"])
if __name__ == "__main__":
unittest.main()

139
tests/test_journal.py Normal file
View File

@ -0,0 +1,139 @@
import json
import os
import tempfile
import unittest
from unittest import mock
from _common import agent
class JournalTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.TemporaryDirectory()
self.path = os.path.join(self.dir.name, "journal.jsonl")
self.j = agent.Journal(self.path)
patcher = mock.patch.object(agent, "log")
self.log = patcher.start()
self.addCleanup(patcher.stop)
def tearDown(self):
self.dir.cleanup()
def test_empty(self):
self.assertEqual(self.j.records(), [])
self.assertEqual(self.j.unacked_finished(), [])
self.assertEqual(self.j.unfinished_started(), [])
def test_lifecycle(self):
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [1])
self.j.finished(1, "done", {"key": "A-1"}, None, 2, 0)
self.assertEqual(self.j.unfinished_started(), [])
pending = self.j.unacked_finished()
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0]["command_id"], 1)
self.assertEqual(pending[0]["result"], {"key": "A-1"})
self.assertEqual(pending[0]["jira_calls"], 2)
self.j.acked(1)
self.assertEqual(self.j.unacked_finished(), [])
self.j.started(2)
self.j.started(3)
self.j.finished(3, "failed", None, "HTTP 400", 1, 0)
self.assertEqual(self.j.unfinished_started(), [2])
self.assertEqual([r["command_id"] for r in self.j.unacked_finished()], [3])
def test_broken_line_is_skipped(self):
self.j.started(1)
with open(self.path, "a", encoding="utf-8") as f:
f.write("{не json\n")
self.j.finished(1, "done", None, None, 1, 0)
self.assertEqual(len(self.j.records()), 2)
self.log.assert_called_with("журнал: строка 2 не читается, пропускаю")
def test_prune_keeps_unacked_and_recent(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
self.j.started(2)
self.j.finished(2, "done", None, None, 1, 0)
self.j.acked(2)
self.j.started(3)
removed = self.j.prune(days=30)
self.assertEqual(removed, 3)
ids = [(r["kind"], r["command_id"]) for r in self.j.records()]
self.assertEqual(ids, [("started", 2), ("finished", 2), ("acked", 2), ("started", 3)])
self.assertEqual(self.j.prune(days=30), 0)
def test_no_secrets_in_records(self):
self.j.finished(1, "done", {"key": "A-1"}, None, 1, 0)
with open(self.path, encoding="utf-8") as f:
text = f.read()
self.assertNotIn("password", text)
self.assertEqual(json.loads(text.splitlines()[0])["kind"], "finished")
def test_torn_tail_is_not_glued(self):
with open(self.path, "wb") as f:
f.write('{"kind": "star'.encode("utf-8"))
self.j.started(1)
recs = self.j.records()
self.assertEqual([(r["kind"], r["command_id"]) for r in recs], [("started", 1)])
self.assertEqual(self.j.unfinished_started(), [1])
def test_restart_after_acked_reopens(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.acked(1)
self.assertEqual(self.j.unfinished_started(), [])
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [1])
def test_duplicate_started_is_deduplicated_in_first_open_order(self):
self.j.started(2)
self.j.started(1)
self.j.started(1)
self.assertEqual(self.j.unfinished_started(), [2, 1])
self.j.finished(2, "done", None, None, 1, 0)
self.assertEqual(self.j.unfinished_started(), [1])
def test_prune_keeps_id_with_newer_records(self):
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00+00:00"})
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
before = self.j.records()
self.assertEqual(self.j.prune(days=30), 0)
self.assertEqual(self.j.records(), before)
def test_non_dict_and_no_command_id_lines_are_skipped(self):
with open(self.path, "wb") as f:
f.write(b'[1, 2]\n5\n{"kind": "started"}\n')
self.j.started(1)
self.j.finished(1, "done", None, None, 1, 0)
self.j.acked(1)
self.assertEqual(len(self.j.records()), 3)
self.assertEqual(self.j.unacked_finished(), [])
self.assertEqual(self.j.unfinished_started(), [])
self.assertEqual(self.j.prune(days=30), 0)
self.log.assert_any_call("журнал: удаляю битых строк 3")
self.assertEqual(len(self.j.records()), 3)
with open(self.path, encoding="utf-8") as f:
self.assertNotIn("[1, 2]", f.read())
def test_naive_and_invalid_at_in_prune(self):
self.j.started(1)
self.j.append({"kind": "acked", "command_id": 1, "at": "2020-01-01T00:00:00"})
self.j.started(2)
self.j.append({"kind": "acked", "command_id": 2, "at": "не дата"})
self.assertEqual(self.j.prune(days=30), 2)
self.assertEqual([(r["kind"], r["command_id"]) for r in self.j.records()], [("started", 2), ("acked", 2)])
def test_journal_file_is_private(self):
self.j.started(1)
mode = os.stat(self.path).st_mode & 0o777
self.assertEqual(mode & 0o077, 0)
if __name__ == "__main__":
unittest.main()

30
tests/test_py39.py Normal file
View File

@ -0,0 +1,30 @@
import ast
import os
import unittest
from _common import SRC
# Ограничение проверки: ast.parse(feature_version=(3, 9)) ловит синтаксис 3.10+ (match и т. п.) и сторонние
# импорты; API и конструкции новее 3.9 (например, `X | Y` — синтаксис 3.10: аннотации функций
# вычисляются при импорте и их ловит прогон на /usr/bin/python3 3.9.6, аннотации локальных переменных не вычисляются), не проверяет.
class Py39Test(unittest.TestCase):
def test_agent_parses_as_python_39(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
ast.parse(f.read(), feature_version=(3, 9))
def test_no_third_party_imports(self):
with open(os.path.join(SRC, "agent.py"), encoding="utf-8") as f:
tree = ast.parse(f.read())
allowed = {"argparse", "base64", "json", "os", "signal", "ssl", "http", "sys", "time", "urllib", "datetime", "typing"}
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for a in node.names:
self.assertIn(a.name.split(".")[0], allowed, a.name)
elif isinstance(node, ast.ImportFrom):
self.assertIn((node.module or "").split(".")[0], allowed, node.module)
if __name__ == "__main__":
unittest.main()

189
tests/test_refresh.py Normal file
View File

@ -0,0 +1,189 @@
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
class RefreshTest(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
def tearDown(self):
self.fake.stop()
self.hub.stop()
def test_to_state_and_links(self):
self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300",
customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2")
self.fake.add("AURORA-2")
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"})
raw = self.fake.issue_json("AURORA-1")
st = self.ex.to_state(raw)
self.assertEqual(st["key"], "AURORA-1")
self.assertEqual(st["assignee"], "ivanov")
self.assertEqual(st["planned_start"], "2026-10-20")
self.assertEqual(st["epic_key"], "AURORA-1350")
self.assertEqual(st["labels"], ["AWPCA", "kind-dev"])
self.assertEqual(st["issue_type"], "Задача")
self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300")
self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh
self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")),
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
self.assertIn("customfield_15302", self.ex.state_fields())
def test_full_refresh_with_neighbors_and_sprints(self):
for i in range(1, 4):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None)
self.fake.add("OTHER-9", in_base=False)
self.fake.add("AURORA-7", in_base=False)
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"})
self.hub.known = ["AURORA-7"]
out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1})
state = self.hub.states[0]
self.assertEqual(state["command_id"], 5)
self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"])
self.assertEqual(state["neighbor_keys"], ["OTHER-9"])
self.assertTrue(state["last_page"])
sprints = {i["key"]: i["sprint"] for i in state["issues"]}
self.assertEqual(sprints["AURORA-1"], "Спринт 2")
self.assertIsNone(sprints["AURORA-3"])
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
self.assertEqual(len(searches), 2) # базовая выборка + дочитывание соседей
def test_refresh_by_keys_pages(self):
for i in range(1, 121):
self.fake.add("AURORA-%d" % i)
keys = ["AURORA-%d" % i for i in range(1, 121)]
out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}})
self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2})
self.assertEqual(len(self.hub.states), 2)
self.assertEqual(self.hub.states[0]["page"], 1)
self.assertFalse(self.hub.states[0]["last_page"])
self.assertTrue(self.hub.states[1]["last_page"])
self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120)
def test_refresh_empty_keys(self):
out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}})
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
self.assertEqual(self.fake.requests, [])
self.assertEqual(self.hub.states, [])
def test_refresh_empty_jql_result(self):
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
self.assertEqual(self.hub.states, [])
def test_keys_with_deleted_key(self):
for i in (1, 2, 3, 4):
self.fake.add("AURORA-%d" % i)
out = self.ex.run({"id": 9, "type": "refresh",
"params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-99", "AURORA-98"]})
self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
def test_all_keys_400_fails(self):
out = self.ex.run({"id": 14, "type": "refresh", "params": {"keys": ["X-1", "X-2", "X-3"]}})
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertEqual(self.hub.states, [])
def test_bisect_cost_one_missing_of_100(self):
for i in range(1, 100):
self.fake.add("AURORA-%d" % i)
keys = ["AURORA-%d" % i for i in range(1, 101)] # AURORA-100 отсутствует
out = self.ex.run({"id": 15, "type": "refresh", "params": {"keys": keys}})
self.assertEqual(out["result"]["missing"], ["AURORA-100"])
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
self.assertLessEqual(len(searches), 2 * 7 + 1)
def test_missing_deduped_and_truncated(self):
self.fake.add("AURORA-1")
keys = ["AURORA-1"] + ["GONE-%d" % i for i in range(1, 61)] + ["GONE-1"]
out = self.ex.run({"id": 16, "type": "refresh", "params": {"keys": keys}})
self.assertEqual(out["result"]["missing"], ["GONE-%d" % i for i in range(1, 51)])
def test_missing_not_duplicated_when_also_a_neighbor(self):
self.fake.add("AURORA-1")
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
out = self.ex.run({"id": 17, "type": "refresh", "params": {"keys": ["AURORA-1", "GONE-1"]}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"]["missing"], ["GONE-1"])
def test_input_keys_deduplicated(self):
self.fake.add("AURORA-1")
out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}})
self.assertEqual(out["result"], {"issues": 1, "neighbors": 0, "pages": 1})
self.assertEqual(len(self.hub.states[0]["issues"]), 1)
self.assertEqual(self.hub.states[0]["agent_version"], agent.AGENT_VERSION)
def test_deleted_neighbor(self):
self.fake.add("AURORA-1")
self.fake.add("AURORA-2", in_base=False)
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
out = self.ex.run({"id": 10, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 1, "neighbors": 1, "pages": 1, "missing": ["GONE-1"]})
self.assertEqual(self.hub.states[0]["neighbor_keys"], ["AURORA-2"])
def test_non_400_error_fails(self):
self.fake.add("AURORA-1")
def boom(jql):
raise agent.HttpError(500, "oops")
self.ex.search_all = boom
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(out["status"], "failed")
self.assertIn("500", out["error"])
def test_link_visible_from_both_ends_deduped(self):
self.fake.add("AURORA-1")
self.fake.add("AURORA-2")
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
self.assertEqual(len(self.hub.states[0]["links"]), 1)
def test_many_neighbors_several_requests(self):
self.fake.add("AURORA-1")
for i in range(1, 151):
self.fake.add("NB-%d" % i, in_base=False)
self.fake.links.append({"id": i, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "NB-%d" % i})
out = self.ex.run({"id": 13, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["result"], {"issues": 1, "neighbors": 150, "pages": 2})
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
self.assertEqual(len(searches), 1 + 2) # база + два чанка соседей
def test_sprint_names_paginated(self):
for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2")
names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)])
self.assertEqual(len(names), 100)
pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"]
self.assertEqual(len(pages), 2)
def test_fake_hub_terminal_result_not_overwritten(self):
cid = self.hub.enqueue("refresh", {"keys": []})
hub = agent.HubClient(jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token))
hub.result(cid, {"status": "done", "result": {"a": 1}})
hub.result(cid, {"status": "failed", "error": "x"})
self.assertEqual(self.hub.commands[cid]["status"], "done")
self.assertEqual(self.hub.results[cid]["status"], "done")
if __name__ == "__main__":
unittest.main()

400
tests/test_runner.py Normal file
View File

@ -0,0 +1,400 @@
import os
import tempfile
import time
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
class RunnerTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
self.dir = tempfile.TemporaryDirectory()
self.journal = agent.Journal(os.path.join(self.dir.name, "journal.jsonl"))
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.runner = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal)
self.sleeps = []
self.runner.sleep = self.sleeps.append
self.runner.jira.sleep = no_sleep
self.runner.idle_limit = 1
self.fake.add("AURORA-1", duedate="2026-10-20")
def tearDown(self):
self.fake.stop()
self.hub.stop()
self.dir.cleanup()
def kinds(self):
return [(r["kind"], r["command_id"]) for r in self.journal.records()]
def claim_in_flight(self, ctype, params, pause=0):
cid = self.hub.enqueue(ctype, params, pause=pause)
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "claimed"
self.hub.in_flight = cid
return cid
def test_processes_queue(self):
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertEqual(self.hub.results[1]["result"], {"changed": ["duedate"]})
self.assertEqual(self.hub.results[1]["jira_calls"], 2)
self.assertEqual(self.hub.results[2]["status"], "done")
self.assertEqual(len(self.hub.states), 1)
self.assertEqual(self.hub.states[0]["issues"][0]["duedate"], "2026-10-23")
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1), ("claimed", 2), ("started", 2), ("finished", 2), ("acked", 2)])
self.assertEqual(self.sleeps, []) # пауз не было: last_call_at пуст, потом pause 0
def test_pause_waits_from_last_jira_call(self):
self.runner.jira.last_call_at = time.monotonic()
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=3)
self.runner.loop()
self.assertEqual(len(self.sleeps), 3)
self.assertAlmostEqual(sum(self.sleeps), 3, delta=0.1)
self.assertEqual(self.hub.results[1]["status"], "done")
def test_result_retried_when_hub_down(self):
self.hub.fail_results = 2
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertEqual(self.sleeps, [10, 10])
self.assertEqual(self.journal.unacked_finished(), [])
def test_result_retried_on_connection_error(self):
calls = []
real = self.runner.hub.result
def flaky(cid, payload):
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return real(cid, payload)
self.runner.hub.result = flaky
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(self.sleeps, [10, 10])
self.assertEqual(self.hub.results[1]["status"], "done")
def test_result_rejected_is_acked(self):
for cid, code in ((1, 404), (2, 409), (3, 422)):
self.hub.reject_results = {cid: code}
self.journal.finished(cid, "done", {}, None, 0, 0)
self.assertTrue(self.runner.send_result(self.journal.unacked_finished()[0]))
self.assertEqual(self.journal.unacked_finished(), [])
def test_result_rejected_in_loop(self):
self.hub.reject_results = {1: 404}
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertNotIn(1, self.hub.results)
self.assertEqual(self.journal.unacked_finished(), [])
self.assertEqual(self.kinds()[-1], ("acked", 1))
def test_recover_unfinished_started(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"}, pause=99)
self.journal.started(cid)
self.runner.loop()
self.assertEqual(self.hub.results[cid]["result"], {"changed": []}) # уже так в Jira — записи нет
self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.sleeps, []) # пауза при восстановлении не применяется
self.assertEqual([r[0] for r in self.fake.writes()], [])
def test_recover_ensure_issue_finds_duplicate(self):
self.fake.add("AURORA-7", reporter="me", summary="Новая задача")
before = len(self.fake.issues)
cid = self.claim_in_flight("ensure_issue", {"temp_key": "new:1", "issue_type": "task", "summary": "Новая задача"})
self.journal.started(cid)
self.runner.loop()
self.assertEqual(self.hub.results[cid]["status"], "done")
self.assertEqual(self.hub.results[cid]["result"], {"key": "AURORA-7", "existed": True})
self.assertEqual(len(self.fake.issues), before)
self.assertEqual(self.fake.writes(), [])
def test_recover_skips_command_not_claimed_anymore(self):
cid = self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.hub.queue.remove(cid)
self.hub.commands[cid]["status"] = "cancelled"
self.journal.started(cid)
self.runner.loop()
self.assertNotIn(cid, self.hub.results)
self.assertEqual(self.kinds(), [("started", cid), ("acked", cid)])
def test_recover_unacked_finished(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.journal.started(cid)
self.journal.finished(cid, "done", {"changed": ["duedate"]}, None, 2, 0)
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
self.runner.loop()
self.assertEqual(self.hub.results[cid]["result"], {"changed": ["duedate"]})
self.assertEqual(self.hub.results[2]["status"], "done")
self.assertEqual(self.kinds()[:3], [("started", 1), ("finished", 1), ("acked", 1)])
def test_stop_during_pause(self):
self.runner.jira.last_call_at = time.monotonic()
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"]) # started нет
self.assertEqual(self.hub.results, {})
self.assertEqual(self.hub.commands[1]["status"], "claimed")
def test_stop_during_pause_then_restart_executes_once(self):
self.runner.jira.last_call_at = time.monotonic()
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"}, pause=5)
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.runner.loop()
self.assertEqual(self.journal.unfinished_started(), [1])
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
second = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), agent.Journal(self.journal.path))
second.sleep = self.sleeps.append
second.idle_limit = 1
second.loop()
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertEqual(len(self.fake.writes()), 1)
self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.sleeps, []) # при восстановлении пауза не применяется
def test_stop_between_claim_and_execute_is_recorded(self):
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
real = self.runner.hub.claim
def claim_then_stop(*a):
resp = real(*a)
self.runner.stop = True
return resp
self.runner.hub.claim = claim_then_stop
self.runner.loop()
self.assertEqual([r["kind"] for r in self.journal.records()], ["claimed"])
self.assertEqual(self.fake.writes(), [])
self.assertEqual(self.journal.unfinished_started(), [1])
def test_stop_during_execution_finishes_and_reports(self):
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.hub.enqueue("refresh", {"keys": ["AURORA-1"]})
self.runner.load_settings()
run = self.runner.executor.run
def run_and_signal(cmd):
self.runner.stop = True # сигнал пришёл во время выполнения
return run(cmd)
self.runner.executor.run = run_and_signal
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertNotIn(2, self.hub.results) # вторая команда не начата
self.assertEqual(self.kinds(), [("claimed", 1), ("started", 1), ("finished", 1), ("acked", 1)])
def test_stop_during_execution_hub_down_single_attempt(self):
self.hub.fail_results = 5
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.load_settings()
run = self.runner.executor.run
self.runner.executor.run = lambda cmd: (setattr(self.runner, "stop", True), run(cmd))[1]
self.runner.loop()
self.assertEqual(self.sleeps, [])
self.assertEqual([r["command_id"] for r in self.journal.unacked_finished()], [1])
def test_unexpected_exception_becomes_failed(self):
self.runner.load_settings()
def boom(cmd):
raise KeyError("summary")
self.runner.executor.run = boom
self.hub.enqueue("ensure_issue", {"temp_key": "new:1", "issue_type": "task"})
self.runner.loop()
self.assertEqual(self.hub.results[1]["status"], "failed")
self.assertIn("KeyError", self.hub.results[1]["error"])
self.assertEqual(self.journal.unfinished_started(), [])
self.assertEqual(self.journal.unacked_finished(), [])
def test_recover_waits_when_hub_down(self):
cid = self.claim_in_flight("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-20"})
self.journal.started(cid)
real = self.runner.hub.command
calls = []
def flaky(i):
calls.append(1)
if len(calls) == 1:
raise ConnectionError("нет связи")
return real(i)
self.runner.hub.command = flaky
self.runner.loop()
self.assertEqual(self.sleeps, [10])
self.assertEqual(self.hub.results[cid]["status"], "done")
def test_settings_reload_on_version_change(self):
self.runner.load_settings()
self.runner.settings["settings_version"] = 0
self.runner.loop()
self.assertEqual(self.runner.settings["settings_version"], 1)
def test_settings_reloaded_before_command(self):
self.runner.load_settings()
self.hub.settings["settings_version"] = 2
self.hub.settings["pacing"] = dict(self.hub.settings["pacing"], ensure_fields=0)
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
seen = []
real = self.runner.execute
self.runner.execute = lambda cmd: (seen.append(self.runner.settings["settings_version"]), real(cmd))
self.runner.loop()
self.assertEqual(seen, [2])
def test_hub_down_at_start_keeps_polling(self):
calls = []
def hub_settings():
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return dict(SETTINGS)
self.runner.hub.settings = hub_settings
self.runner.loop()
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [1, 1]) # пол паузы на ошибках хаба — 1 с
def test_retry_after_connection_drop_creates_one_issue(self):
self.fake.drop_next_post_issue = True
self.hub.enqueue("ensure_issue", {"temp_key": "new:1", "issue_type": "task", "summary": "Новая задача"})
self.runner.loop()
r = self.hub.results[1]
self.assertEqual(r["status"], "done")
self.assertEqual(r["result"]["existed"], True)
self.assertEqual(len([k for k in self.fake.issues if k != "AURORA-1"]), 1)
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5) # ожидание 30 с перед повтором
self.assertEqual(r["jira_calls"], 2) # поиск + повторный поиск (оборванный ответ не считается)
self.assertEqual([k for k, _ in self.kinds()].count("started"), 1)
def test_retry_waits_from_failure_even_if_last_call_is_old(self):
# F-1: Jira недоступна с первого вызова, last_call_at старый (обрыв не обновляет его) —
# ожидание перед повтором считается от момента отказа, а не от последнего успешного вызова.
self.runner.load_settings()
self.runner.jira.last_call_at = time.monotonic() - 600
runs = []
def flaky(cmd):
runs.append(1)
if len(runs) == 1:
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 0,
"rate_limited": 0, "retryable": True}
return {"status": "done", "result": {"changed": []}, "jira_calls": 1, "rate_limited": 0}
self.runner.executor.run = flaky
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(len(runs), 2)
self.assertEqual(self.hub.results[1]["status"], "done")
self.assertAlmostEqual(sum(self.sleeps), 30, delta=0.1) # полные 30 с от момента отказа
def test_retry_exhausted_gives_failed_unknown(self):
self.runner.load_settings()
runs = []
def down(cmd):
runs.append(1)
return {"status": "failed", "result": None, "error": "нет связи", "jira_calls": 1, "rate_limited": 0, "retryable": True}
self.runner.executor.run = down
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(len(runs), 4) # первый запуск + 3 повтора
r = self.hub.results[1]
self.assertEqual(r["status"], "failed")
self.assertIn("результат неизвестен", r["error"])
self.assertEqual(r["jira_calls"], 4)
def test_stop_during_retry_wait_leaves_command_for_recovery(self):
self.runner.load_settings()
self.runner.executor.run = lambda cmd: {"status": "failed", "result": None, "error": "x", "jira_calls": 1,
"rate_limited": 0, "retryable": True}
self.runner.sleep = lambda s: setattr(self.runner, "stop", True)
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(self.journal.unfinished_started(), [1])
self.assertEqual(self.hub.results, {})
def test_agent_version_in_result(self):
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
self.runner.loop()
self.assertEqual(self.hub.results[1]["agent_version"], agent.AGENT_VERSION)
def test_hub_auth_error_waits_60(self):
def denied(*a):
raise agent.HttpError(401, "unauthorized")
self.runner.hub.claim = denied
self.runner.sleep = lambda s: (self.sleeps.append(s), setattr(self.runner, "stop", True))
self.runner.loop()
self.assertEqual(self.sleeps, [60])
def test_wrong_hub_token_401_real_hub(self):
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token="bad")
r = agent.Runner(cfg, agent.HubClient(cfg), agent.JiraClient(cfg), self.journal)
r.sleep = lambda s: (self.sleeps.append(s), setattr(r, "stop", True))
r.loop()
self.assertEqual(self.sleeps, [60])
def test_settings_reload_failure_retried_before_command(self):
self.runner.load_settings()
self.hub.settings["settings_version"] = 2
self.hub.enqueue("ensure_fields", {"key": "AURORA-1", "duedate": "2026-10-23"})
real = self.runner.hub.settings
calls = []
def flaky():
calls.append(1)
if len(calls) < 3:
raise ConnectionError("нет связи")
return real()
self.runner.hub.settings = flaky
self.runner.loop()
self.assertEqual(len(calls), 3)
self.assertEqual(self.sleeps, [1, 1])
self.assertEqual(self.runner.settings["settings_version"], 2)
self.assertEqual(self.hub.results[1]["status"], "done")
def test_claim_timeout_at_least_45(self):
seen = []
real = agent.Http.call
agent.Http.call = lambda self_, m, p, body=None, params=None, timeout=None: (seen.append(timeout), {"command": None, "settings_version": 1})[1]
try:
agent.HubClient(jira_cfg(self.fake, hub_url="http://x", hub_token="t", timeout=5)).claim(1, 1, None)
finally:
agent.Http.call = real
self.assertEqual(seen, [45])
def test_signal_handler(self):
import signal
from unittest import mock
handlers = {}
seen = {}
def fake_loop(runner):
handlers[signal.SIGINT](signal.SIGINT, None)
seen["stop_after_first"] = runner.stop
with self.assertRaises(SystemExit) as cm:
handlers[signal.SIGINT](signal.SIGINT, None)
seen["code"] = cm.exception.code
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
with mock.patch.object(agent.signal, "signal", lambda n, h: handlers.__setitem__(n, h)), \
mock.patch.object(agent.Runner, "loop", fake_loop):
agent.cmd_run(cfg, self.journal.path)
self.assertTrue(seen["stop_after_first"])
self.assertEqual(seen["code"], 130)
if __name__ == "__main__":
unittest.main()