Merge pull request 'feat: ядро моста Jira — агент (bridge-core, #1)' (#2) from feat/1-bridge-core into main
This commit is contained in:
commit
3b9b3b35c2
@ -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).
|
||||
|
||||
44
CLAUDE.md
44
CLAUDE.md
@ -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
181
README.md
Normal 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 с × попытка.
|
||||
2222
docs/superpowers/plans/2026-10-11-bridge-core-agent.md
Normal file
2222
docs/superpowers/plans/2026-10-11-bridge-core-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
21
openspec/changes/bridge-core/design.md
Normal file
21
openspec/changes/bridge-core/design.md
Normal 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
12
src/agent.example.json
Normal 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
1061
src/agent.py
Normal file
File diff suppressed because it is too large
Load Diff
7
tests/__init__.py
Normal file
7
tests/__init__.py
Normal 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
46
tests/_common.py
Normal 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
110
tests/fake_hub.py
Normal 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
258
tests/fake_jira.py
Normal 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
204
tests/test_clients.py
Normal 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()
|
||||
202
tests/test_executor_fields.py
Normal file
202
tests/test_executor_fields.py
Normal 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()
|
||||
194
tests/test_executor_links.py
Normal file
194
tests/test_executor_links.py
Normal 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
139
tests/test_journal.py
Normal 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
30
tests/test_py39.py
Normal 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
189
tests/test_refresh.py
Normal 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
400
tests/test_runner.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user