Compare commits
80 Commits
chore/23-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43daa4af76 | |||
|
|
8e7ebcd272 | ||
| 6423a5c2ad | |||
|
|
20747c2b65 | ||
|
|
a3247d6f2d | ||
|
|
cb8203f0e1 | ||
|
|
3350d0066a | ||
|
|
3dbca034ad | ||
| 2a82f13433 | |||
|
|
cd4b68e0d3 | ||
| 9095efe921 | |||
|
|
d71c2f5180 | ||
|
|
9d3958e337 | ||
|
|
3693578648 | ||
|
|
b00fa16d54 | ||
|
|
5dba989015 | ||
|
|
b1716d5631 | ||
|
|
33d8a1e7e3 | ||
|
|
73cbb727ad | ||
|
|
54f5781b98 | ||
|
|
70825ff4d1 | ||
|
|
255784c858 | ||
|
|
514e98e819 | ||
|
|
0ea1438b1e | ||
|
|
9de78842b1 | ||
|
|
79f18abbfa | ||
|
|
21374bef08 | ||
|
|
9fbe885ba8 | ||
|
|
0edea89133 | ||
|
|
9eddc13b62 | ||
| 0cf4aa9535 | |||
|
|
75693e821c | ||
| c092c6e21c | |||
|
|
0527a1768e | ||
| 78e4f02ba8 | |||
|
|
b7978849b6 | ||
|
|
3a5bd9a109 | ||
|
|
7238691a5d | ||
|
|
4819950738 | ||
|
|
711f62e1df | ||
|
|
0d72369875 | ||
|
|
ceadd9c071 | ||
|
|
7778b6dbbc | ||
| ffb173e7aa | |||
|
|
b2db9127bc | ||
| 4e07de32ed | |||
|
|
4ed011bcc0 | ||
|
|
faed8b9c7a | ||
|
|
05e3f2c114 | ||
|
|
71e182a162 | ||
|
|
de2d5b5252 | ||
|
|
285cc1c484 | ||
|
|
ea2e150f9a | ||
| a6dbd6542d | |||
|
|
5bac8aa2eb | ||
| 3fe6370cd6 | |||
|
|
ced48cf9db | ||
|
|
719d4bceef | ||
|
|
9dd3dd7d9d | ||
|
|
fc87f54cf9 | ||
|
|
f3e899ec2c | ||
| 69160bbd5d | |||
|
|
1d79ee6e0d | ||
| 6740ccf5b8 | |||
|
|
04e78128e3 | ||
|
|
93e5595c40 | ||
|
|
d75c1a5567 | ||
|
|
2bf87a6fa2 | ||
|
|
90dc429434 | ||
|
|
cd8213b71c | ||
|
|
da5d0b1d6d | ||
| 7d90c9b9fc | |||
|
|
601005c526 | ||
| a799c54da7 | |||
|
|
e082296ee0 | ||
|
|
28b21e0d54 | ||
|
|
06375b9d90 | ||
|
|
f5ea74bbcd | ||
|
|
92679fdf3a | ||
| dd0f9f2d18 |
@ -1,17 +1,17 @@
|
||||
# Архитектура
|
||||
|
||||
Агент — один файл `src/agent.py` и запускатель `src/run.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`).
|
||||
Jira и хаба. Спека: `../sdlc-hub/openspec/changes/bridge-core/`, план агента: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md` (план хаба: `../sdlc-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` |
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` |
|
||||
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `sdlc-hub/openspec/changes/folder-transport/`) | `os`, `uuid` |
|
||||
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб | `JiraClient`, `HubClient` (только `refresh`), настройки хаба |
|
||||
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной; v15: поля ранга, спринта и оценки — `jira_fields` по `field_list` (`GET /field` один раз на команду; перекрытие `field_ids`), доски — `board_state` (спринты через `all_sprints`), новые `do_ensure_sprint_meta`, `do_ensure_sprint_state`, `do_ensure_rank`, `story_points` в `do_ensure_fields`; v16: оценка с `board_id` — через `/rest/agile/1.0/issue/{key}/estimation` доски, остальные поля — `ensure_issue_fields` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||
@ -43,4 +43,4 @@ agent-v9-hardening); коды агента: 75 — подмена, 73 — жур
|
||||
|
||||
## Открытые вопросы
|
||||
- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 2).
|
||||
- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6).
|
||||
- Поле `sprint` задач закрытых спринтов: `sprint = null` (рулинг R-6); полная история спринтов — в `sprint_ids` (v15).
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@ -1,11 +1,11 @@
|
||||
# jira-agent
|
||||
|
||||
## Что это
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Опрашивает хаб `jira-hub` (Mac mini), выполняет команды
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Опрашивает хаб `sdlc-hub` (Mac mini), выполняет команды
|
||||
«желаемый результат» по правилу «проверь, потом сделай», ведёт журнал на диске и отдаёт хабу состояние задач.
|
||||
Главный сценарий: хаб ставит команду (например, создать задачу, перевести статус, перенести в спринт), агент
|
||||
читает задачу в Jira, записывает только разницу и возвращает результат; хаб синхронизирует зеркало через `refresh`.
|
||||
Спека: `../jira-hub/openspec/specs/` (`agent/spec.md`, `commands/spec.md`).
|
||||
Спека: `../sdlc-hub/openspec/specs/` (`agent/spec.md`, `commands/spec.md`).
|
||||
|
||||
## Стек и ограничения
|
||||
- Python 3.9.6 (как на виртуалке), только стандартная библиотека, без venv и зависимостей.
|
||||
@ -15,12 +15,12 @@
|
||||
Шаблон без секретов — `src/agent.example.json`.
|
||||
|
||||
## Архитектура
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `jira-hub/openspec/specs/agent/`),
|
||||
- `src/agent.py` — весь агент, один файл. Разделы в порядке файла: HTTP-клиенты (`Http`, `JiraClient`, `HubClient`; `FolderTransport` — транспорт `folder:` к хабу через общую папку, ошибки как у `Http`, спека `sdlc-hub/openspec/specs/agent/`),
|
||||
`Journal`, `Executor` (по методу `do_<тип>` на каждый тип команды), `Runner` (цикл claim → пауза → журнал →
|
||||
выполнение → отчёт), `Updater` (самообновление: `update.json`, скачивание, `selftest`, выход 75), CLI (`cmd_check`,
|
||||
`cmd_selftest`, `cmd_run`, `cmd_prune`, `main`).
|
||||
- `src/run.py` — запускатель: дочерний `agent.py run`, подмена версии, пробный период, откат, перезапуск; `agent.py` не импортирует.
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`).
|
||||
- Границы: `Executor` знает Jira, но не знает хаб (кроме `refresh`, где хаб нужен для `known_keys` и `state`, `ensure_attachment`, где у хаба берётся файл вложения, и `fetch_attachment`, где скачанный из Jira файл отдаётся хабу).
|
||||
`Runner` знает и хаб, и Jira, и журнал. Журнал не знает о Jira.
|
||||
- `src/agent.example.json` — шаблон настроек, `README.md` — эксплуатация.
|
||||
- Подробнее: `ARCHITECTURE.md`.
|
||||
@ -39,7 +39,7 @@
|
||||
- `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_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_sprints.py` (`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`),
|
||||
`test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
|
||||
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
|
||||
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
|
||||
@ -48,12 +48,12 @@
|
||||
## Первая подсистема
|
||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||
Последний change — `../jira-hub/openspec/changes/archive/2026-10-15-agent-v9-hardening/` (решения агента — `openspec/changes/archive/2026-10-15-agent-v9-hardening/design.md`), план — `docs/superpowers/plans/2026-10-14-agent-v9-hardening-agent.md`.
|
||||
План и спека живут в `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`.
|
||||
Последний change — `../sdlc-hub/openspec/changes/sp-estimation-board/` (агент v16: оценка через доску; решения агента — `openspec/changes/agent-v16-sp-estimation/design.md`), план — `docs/superpowers/plans/2026-10-25-sp-estimation-agent.md`.
|
||||
План и спека живут в `sdlc-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`.
|
||||
|
||||
## Выкатка новой версии
|
||||
Самообновление (с v5): после merge PR агента в main — поднять `AGENT_VERSION` (если не поднят в PR) и опубликовать:
|
||||
`uv run --project ../jira-hub jira-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
|
||||
`uv run --project ../sdlc-hub sdlc-hub agent publish src/agent.py` (из основного checkout `jira-agent` на main).
|
||||
Через несколько минут MCP `agent_status` должен показать «обновление: ok v<старая> → v<новая>»; откат — «откат с v<N>…»
|
||||
(причина — в логе агента на виртуалке). Это пункт чеклиста «закрывай» для PR агента. `run.py` публикацией не
|
||||
обновляется — только вручную через общую папку.
|
||||
|
||||
65
README.md
65
README.md
@ -1,12 +1,12 @@
|
||||
# jira-agent
|
||||
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`jira-hub` на Mac mini),
|
||||
Клиент моста Jira для виртуалки с доступом к Jira банка. Агент опрашивает хаб (`sdlc-hub` на Mac mini),
|
||||
выполняет команды «желаемый результат» в Jira по правилу «проверь, потом сделай», ведёт журнал на диске
|
||||
и возвращает хабу состояние задач. Один файл `src/agent.py` и запускатель `src/run.py`, только стандартная
|
||||
библиотека, Python 3.9.
|
||||
|
||||
Спека: `../jira-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
|
||||
Хаб, очередь команд и зеркало задач живут в репозитории `jira-hub`; агент с ним общается только по HTTP.
|
||||
Спека: `../sdlc-hub/openspec/specs/agent/spec.md` (вместе с `commands/` и `hub-api/`).
|
||||
Хаб, очередь команд и зеркало задач живут в репозитории `sdlc-hub`; агент с ним общается только по HTTP.
|
||||
|
||||
## Доставка на виртуалку
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
виртуалке, например `~/jira-agent/`. Запускать из этого каталога, не из общей папки.
|
||||
2. Переименовать `agent.example.json` в `agent.json` и заполнить:
|
||||
- `hub_url` — `http://<адрес Mac mini>:8790`, или, если сеть до Mac mini закрыта VPN (Континент), `folder:/Volumes/My Shared Files/RSHB/jira-bridge` — обмен через общую папку UTM (на хабе должна быть задана настройка `relay_dir`, см. README хаба);
|
||||
- `hub_token` — агентский токен из `jira-hub tokens` на Mac mini;
|
||||
- `hub_token` — агентский токен из `sdlc-hub tokens` на Mac mini;
|
||||
- `jira.base_url` — адрес Jira банка;
|
||||
- `jira.auth` — `{"type": "basic", "user": ..., "password": ...}` или `{"type": "bearer", "token": ...}`;
|
||||
- `verify_ssl`, `ca_bundle` — при корпоративном сертификате (см. ограничение 2 ниже);
|
||||
@ -24,8 +24,10 @@
|
||||
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
|
||||
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
|
||||
|
||||
Остальные настройки (id полей, типы задач, карта переходов, типы связей, доска, темп) агент получает от хаба при
|
||||
старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно.
|
||||
Остальные настройки (id полей, типы задач, карта переходов, типы связей, темп) агент получает от хаба при
|
||||
старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. Доски Jira (с v12) приходят в
|
||||
параметрах команд: `ensure_sprint` — `board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд
|
||||
старого хаба.
|
||||
|
||||
## Проверка
|
||||
|
||||
@ -86,7 +88,7 @@ nohup python3 -u run.py > agent.log 2>&1 &
|
||||
|
||||
## Самообновление
|
||||
|
||||
С версии 5 агент обновляется сам. После `jira-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
|
||||
С версии 5 агент обновляется сам. После `sdlc-hub agent publish` на Mac mini хаб сообщает новую версию в ответе на
|
||||
`claim`. Агент в паузе между командами (очередь пуста, отчёты подтверждены) скачивает файл по `GET /agent/release`,
|
||||
сверяет sha256, компилирует, проверяет `AGENT_VERSION`, кладёт рядом `agent.py.new`, запускает его `selftest` и выходит
|
||||
с кодом 75. `run.py` копирует текущий `agent.py` в `agent.py.prev`, ставит новый и запускает его в пробном режиме:
|
||||
@ -165,13 +167,18 @@ python3 agent.py prune
|
||||
|
||||
| Тип | Что делает |
|
||||
|---|---|
|
||||
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт; возвращает `key` |
|
||||
| `ensure_fields` | читает задачу и пишет только отличающиеся поля |
|
||||
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` |
|
||||
| `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка); с v16 при `board_id` в команде (хаб ставит доску проекта) оценка читается и пишется через `/rest/agile/1.0/issue/{key}/estimation?boardId=…` — как в бэклоге Scrum, без экрана редактирования; `GET /issue` — только если есть другие поля |
|
||||
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
|
||||
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
|
||||
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
||||
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог |
|
||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет |
|
||||
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
|
||||
| `ensure_sprint_meta` | (v15) имя, даты и цель спринта доски `board_id` команды; спринта нет — создаёт, есть — правит только отличающиеся поля; `rename_to` переименовывает (повтор после переименования безопасен); даты — `ГГГГ-ММ-ДД`, в Jira — 09:00 и 18:00 по локальному времени агента; `goal: ""` очищает цель, отсутствующий `goal` цель не трогает; закрытый спринт не правится — `failed` |
|
||||
| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; «выполнено» — по id статусов последней колонки доски (не по именам); все проверки — до первой записи; уже в нужном состоянии — `done` |
|
||||
| `ensure_rank` | (v15) ставит задачу `key` выше (`before`) или ниже (`after`) другой задачи, ровно одно из двух; поле ранга ищется по `GET /field`; задача уже на месте — ничего не пишет; `entries` с неуспешным статусом в ответе Jira — `failed` |
|
||||
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
||||
| `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `<base_url>/secure/attachment/<id>/<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется |
|
||||
| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого; с v15 — ранг (`rank`), оценка (`story_points`), история спринтов задач (`sprint_ids`, из поля спринта `search`), данные досок `boards` в первой пачке, пустая пачка с `boards` при пустой выборке); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
||||
|
||||
Правила работы:
|
||||
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
||||
@ -189,11 +196,29 @@ python3 agent.py prune
|
||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||
по сигналу команду подхватит восстановление.
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 9) отправляется в каждом `claim`, в каждом отчёте
|
||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 15) отправляется в каждом `claim`, в каждом отчёте
|
||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||||
`refresh` (номера 6–8 заняты живой проверкой самообновления).
|
||||
Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации
|
||||
(тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30).
|
||||
Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`.
|
||||
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
|
||||
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
|
||||
выкатывается только после v12 (design хаба `projects`, решение 14).
|
||||
Версия 13 — журнал изменений Jira в `refresh` (параметр `history`: `since` — изменившиеся задачи, `jql` — стартовая
|
||||
загрузка), отправка `POST /agent/history`, итог в `result.history` (design хаба `change-history`).
|
||||
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
|
||||
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
|
||||
`fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»).
|
||||
Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points`
|
||||
в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field`
|
||||
(ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`.
|
||||
Версия 16 — оценка из `ensure_fields` с `board_id` через `/rest/agile/1.0/issue/{key}/estimation` доски: в Jira банка поле
|
||||
оценки не на экране редактирования, `PUT /issue` его отклоняет (#53).
|
||||
Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает
|
||||
«неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки.
|
||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||
при этом выдаются по-прежнему.
|
||||
|
||||
@ -230,11 +255,23 @@ python3 agent.py prune
|
||||
следующем `claim` — агент выполнит её через проверку.
|
||||
2. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url`
|
||||
должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают.
|
||||
3. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по
|
||||
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`.
|
||||
3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по
|
||||
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного
|
||||
типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если
|
||||
scrum-доска стала канбаном, `refresh` пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по
|
||||
другой причине (удалена, нет доступа), `refresh` завершается `failed` с номером доски и текстом Jira (R-32).
|
||||
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
|
||||
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
|
||||
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).
|
||||
С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
|
||||
и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
|
||||
`/issue/{key}/comment`.
|
||||
С v11 комментарий без `id` пропускается (строка в логе с ключом задачи), а если Jira не дала дочитать комментарии
|
||||
задачи (403 или 404), задача уходит без `comments` — хаб оставляет прежние; `refresh` продолжается.
|
||||
С v13 после состояния `refresh` читает журнал изменений (если хаб передал `history`): по задачам этого прогона с
|
||||
`updated` не раньше `history.since` и по стартовой выборке `history.jql`. Неполный журнал дочитывается
|
||||
`GET /issue/{key}?expand=changelog`, всё ещё неполный — в логе «получено N из M» и в `result.history.incomplete`.
|
||||
Ошибка Jira или хаба (не 2xx) на этом шаге не роняет `refresh`: текст в `result.history.error` или `backfill_error`.
|
||||
5. **Сравнение текстов без различия переводов строк.** Описание и тело комментария сравниваются после замены `\r\n` на
|
||||
`\n` (Jira хранит правленное в UI с `\r\n`); отправляется текст команды как есть. Прочие различия (пробелы,
|
||||
завершающий перевод строки) — изменение.
|
||||
|
||||
343
docs/superpowers/plans/2026-10-15-agent-v10-comments.md
Normal file
343
docs/superpowers/plans/2026-10-15-agent-v10-comments.md
Normal file
@ -0,0 +1,343 @@
|
||||
# agent-v10-comments — агент v10: комментарии задач в состоянии — план
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `refresh` агента передаёт хабу комментарии каждой задачи (`comments: [{id, author, created, updated, body}]`),
|
||||
чтобы карточка задачи веб-приложения хаба (aep/jira-hub#37) могла их показать; выпуск — агент v10.
|
||||
|
||||
**Architecture:** `src/agent.py` — поле `comment` в `Executor.STATE_FIELDS` (тот же `search`, без новых вызовов Jira);
|
||||
`Executor.comment_state` (форма одного комментария) и `Executor.comments_of` (комментарии из ответа `search`, при
|
||||
неполном списке — дочитывание `GET /issue/{key}/comment` по 100); `do_refresh` кладёт `comments` в состояние каждой
|
||||
задачи. Фейк `tests/fake_jira.py` отдаёт `fields.comment` как Jira Server (с `total`) и умеет обрезать список в поиске.
|
||||
|
||||
**Tech Stack:** Python 3.9.6, только стандартная библиотека; `unittest`.
|
||||
|
||||
**Spec:** `../jira-hub/openspec/changes/issue-card-modal/` — `specs/agent/spec.md` (MODIFIED «Чтение состояния»),
|
||||
`design.md` решения 1–4 и R-1 (worktree спеки: `/Users/andrey.epifancev/projects/awp-ca/jira-hub/.wt/issue-modal`).
|
||||
Решения агентской части — `openspec/changes/agent-v10-comments/design.md` (создаётся в Task 2).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/awp-ca/jira-agent/.wt/agent-v10`, ветка `feat/27-agent-v10-comments`.
|
||||
Все команды — из корня worktree.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+ запрещён (`tests/test_py39.py`).
|
||||
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь набор.
|
||||
- `AGENT_VERSION = 10`.
|
||||
- Комментарий в состоянии: `id` — строка; `author` — `displayName`, иначе `name`, иначе `None`; `created`, `updated` —
|
||||
строки Jira как есть; `body` — текст как есть, пустой — `""`. Порядок — как отдала Jira.
|
||||
- Дочитывание: `GET /issue/{key}/comment`, `startAt`, `maxResults = 100`, только если в ответе `search`
|
||||
`len(comment.comments) < comment.total`.
|
||||
- `comments` есть у каждой задачи состояния, в том числе у соседей; нет комментариев — `[]`.
|
||||
- Коммиты на русском, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Ответ `search` без `fields.comment` вовсе (поле недоступно по правам) — `comments = []`, `refresh` не падает (Task 1, тест `test_comments_field_missing`).
|
||||
2. `comment.total` отсутствует или `null` — считать полным полученный список, без дочитывания (Task 1, тот же тест).
|
||||
3. Автор комментария удалён из Jira (`author` = `null`) — `author = None`, текст на месте (Task 1, `test_comments_in_state`).
|
||||
4. Комментарий с пустым или отсутствующим `body` — `""`, не `None` (Task 1, `test_comments_in_state`).
|
||||
5. Дочитывание вернуло 429 — повтор по общему правилу `JiraClient`, как у любого вызова (покрыто `test_clients.py`; отдельного теста не нужно, вызов идёт через `self.jira.get`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Комментарии в состоянии `refresh`
|
||||
|
||||
**Модель исполнителя:** haiku (готовый код).
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/fake_jira.py` (комментарии с автором и временем, `fields.comment` в `issue_json`, обрезка в поиске)
|
||||
- Modify: `src/agent.py:829-830` (`STATE_FIELDS`), раздел `# --- refresh` (`comment_state`, `comments_of`), `do_refresh`
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Executor.comment_state(c: dict) -> dict`, `Executor.comments_of(raw: dict) -> List[dict]`; у каждой задачи
|
||||
в `POST /agent/state` поле `comments`.
|
||||
- Фейк: `FakeJira.add_comment(key, body, name="ivanov", display="Иванов Иван", created=..., updated=None) -> dict`,
|
||||
`FakeJira.search_comment_cap: Optional[int]`.
|
||||
|
||||
- [ ] **Step 1: Фейк Jira — комментарии как в Jira Server**
|
||||
|
||||
В `tests/fake_jira.py`, в `__init__` после `self.drop_next_post_issue = False` добавить:
|
||||
|
||||
```python
|
||||
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
||||
```
|
||||
|
||||
Добавить метод после `add`:
|
||||
|
||||
```python
|
||||
def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
|
||||
updated=None):
|
||||
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
|
||||
self.comment_seq += 1
|
||||
c = {"id": str(self.comment_seq), "body": body, "author": author, "created": created, "updated": updated or created}
|
||||
self.comments.setdefault(key, []).append(c)
|
||||
return c
|
||||
```
|
||||
|
||||
Заменить `issue_json` целиком:
|
||||
|
||||
```python
|
||||
def issue_json(self, key, fields=None):
|
||||
"""fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{key}."""
|
||||
f = self.issues[key]
|
||||
out = {"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)}
|
||||
out.update({c: f.get(c) for c in CUSTOM})
|
||||
if fields is None or "comment" in fields:
|
||||
lst = self.comments.get(key, [])
|
||||
cap = self.search_comment_cap if fields is not None else None
|
||||
shown = lst if cap is None else lst[:cap]
|
||||
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
|
||||
"comments": shown}
|
||||
return {"key": key, "id": f["id"], "fields": out}
|
||||
```
|
||||
|
||||
В `route`, ветка `/rest/api/2/search`, заменить строку возврата:
|
||||
|
||||
```python
|
||||
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
|
||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
|
||||
"issues": [self.issue_json(k, fields) for k in page]}
|
||||
```
|
||||
|
||||
В ветке `POST .../comment` (создание комментария) заменить две строки
|
||||
|
||||
```python
|
||||
self.comment_seq += 1
|
||||
lst.append({"id": str(self.comment_seq), "body": body["body"]})
|
||||
```
|
||||
|
||||
на
|
||||
|
||||
```python
|
||||
self.comment_seq += 1
|
||||
ts = self.ts(self.now)
|
||||
lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"),
|
||||
"created": ts, "updated": ts})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Падающие тесты**
|
||||
|
||||
В `tests/test_refresh.py`, в класс `RefreshTest`, после `test_refresh_empty_keys` добавить:
|
||||
|
||||
```python
|
||||
def test_comments_in_state(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add("AURORA-2")
|
||||
self.fake.add_comment("AURORA-1", "первый\nвторая строка", name="petrov", display="Петров Пётр",
|
||||
created="2026-10-14T10:00:00.000+0300")
|
||||
self.fake.add_comment("AURORA-1", "второй", name="ivanov", display=None, created="2026-10-14T11:00:00.000+0300",
|
||||
updated="2026-10-14T12:00:00.000+0300")
|
||||
self.fake.add_comment("AURORA-1", None, name=None, display=None) # автор удалён, текста нет
|
||||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
c = by_key["AURORA-1"]["comments"]
|
||||
self.assertEqual([x["author"] for x in c], ["Петров Пётр", "ivanov", None])
|
||||
self.assertEqual(c[0], {"id": c[0]["id"], "author": "Петров Пётр", "created": "2026-10-14T10:00:00.000+0300",
|
||||
"updated": "2026-10-14T10:00:00.000+0300", "body": "первый\nвторая строка"})
|
||||
self.assertIsInstance(c[0]["id"], str)
|
||||
self.assertEqual(c[1]["updated"], "2026-10-14T12:00:00.000+0300")
|
||||
self.assertEqual(c[2]["body"], "")
|
||||
self.assertEqual(by_key["AURORA-2"]["comments"], [])
|
||||
self.assertIn("comment", self.ex.state_fields().split(","))
|
||||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||||
self.assertEqual(len(searches), 1) # комментарии пришли тем же search
|
||||
self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests))
|
||||
|
||||
def test_comments_partial_list_is_read_fully(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for n in range(130):
|
||||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||||
self.fake.search_comment_cap = 20
|
||||
out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
c = self.hub.states[0]["issues"][0]["comments"]
|
||||
self.assertEqual(len(c), 130)
|
||||
self.assertEqual([x["body"] for x in c[:2]] + [c[-1]["body"]], ["к0", "к1", "к129"])
|
||||
reads = [r for r in self.fake.requests if r == ("GET", "/rest/api/2/issue/AURORA-1/comment")]
|
||||
self.assertEqual(len(reads), 2) # 100 + 30
|
||||
|
||||
def test_comments_field_missing(self):
|
||||
raw = {"key": "AURORA-1", "fields": {}} # поле comment недоступно
|
||||
self.assertEqual(self.ex.comments_of(raw), [])
|
||||
raw = {"key": "AURORA-1", "fields": {"comment": {"comments": [{"id": "5", "body": "x"}]}}} # total нет
|
||||
self.assertEqual(self.ex.comments_of(raw),
|
||||
[{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}])
|
||||
self.assertEqual(self.fake.requests, []) # без дочитывания
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Тесты падают**
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||||
Expected: FAIL — `KeyError: 'comments'` в `test_comments_in_state` и `test_comments_partial_list_is_read_fully`,
|
||||
`AttributeError: 'Executor' object has no attribute 'comments_of'` в `test_comments_field_missing`.
|
||||
|
||||
- [ ] **Step 4: Реализация в агенте**
|
||||
|
||||
В `src/agent.py` заменить `STATE_FIELDS`:
|
||||
|
||||
```python
|
||||
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
|
||||
"issuetype", "created", "updated", "issuelinks", "comment"]
|
||||
```
|
||||
|
||||
После метода `links_from` добавить:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def comment_state(c: dict) -> dict:
|
||||
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""
|
||||
a = c.get("author") or {}
|
||||
return {"id": str(c.get("id")), "author": a.get("displayName") or a.get("name") or None,
|
||||
"created": c.get("created"), "updated": c.get("updated"), "body": c.get("body") or ""}
|
||||
|
||||
def comments_of(self, raw: dict) -> List[dict]:
|
||||
"""Комментарии задачи из ответа search. Если Jira отдала не все (len < total) — дочитать постранично
|
||||
через /issue/{key}/comment и взять полный список вместо частичного."""
|
||||
block = (raw.get("fields") or {}).get("comment") or {}
|
||||
got = list(block.get("comments") or [])
|
||||
total = block.get("total")
|
||||
if total is not None and len(got) < int(total):
|
||||
got, start = [], 0
|
||||
while True:
|
||||
page = self.jira.get(API + "/issue/%s/comment" % raw["key"], startAt=start, maxResults=100)
|
||||
part = page.get("comments") or []
|
||||
got += part
|
||||
start += len(part)
|
||||
if not part or page.get("total") is None or start >= int(page["total"]):
|
||||
break
|
||||
return [self.comment_state(c) for c in got]
|
||||
```
|
||||
|
||||
В `do_refresh`, в цикле `for r in everything:` после строки `st["sprint"] = names.get(r["key"])` добавить:
|
||||
|
||||
```python
|
||||
st["comments"] = self.comments_of(r)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Тесты проходят, весь набор зелёный**
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||||
Expected: PASS (все тесты `RefreshTest`, включая три новых).
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: `OK` (ни одного FAIL/ERROR; `test_executor_fields` с `ensure_comment` — тоже зелёный: форма созданного фейком
|
||||
комментария дополнена, но `body` и `id` прежние).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/fake_jira.py tests/test_refresh.py
|
||||
git commit -m "feat(agent): комментарии задач в состоянии refresh (#27)
|
||||
|
||||
Поле comment в том же search; при неполном списке — дочитывание /issue/{key}/comment по 100.
|
||||
Спека: jira-hub/openspec/changes/issue-card-modal/ (agent, «Чтение состояния»).
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task 2: Версия 10, документация, change решений агента
|
||||
|
||||
**Модель исполнителя:** haiku (готовый текст).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
|
||||
- Modify: `tests/test_folder.py:161`
|
||||
- Modify: `README.md` (раздел особенностей `refresh`, пункт 4)
|
||||
- Modify: `CLAUDE.md` (раздел «Первая подсистема» — последний change)
|
||||
- Create: `openspec/changes/agent-v10-comments/design.md` (через `openspec new change agent-v10-comments`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 (`comments` в состоянии).
|
||||
- Produces: `AGENT_VERSION = 10`.
|
||||
|
||||
- [ ] **Step 1: Падающий тест версии**
|
||||
|
||||
В `tests/test_folder.py` строку 161 `self.assertEqual(agent.AGENT_VERSION, 9)` заменить на:
|
||||
|
||||
```python
|
||||
self.assertEqual(agent.AGENT_VERSION, 10)
|
||||
```
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||||
Expected: FAIL — `AssertionError: 9 != 10`.
|
||||
|
||||
- [ ] **Step 2: Версия**
|
||||
|
||||
В `src/agent.py` строку `AGENT_VERSION = 9` заменить на `AGENT_VERSION = 10`.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: README**
|
||||
|
||||
В `README.md` найти пункт 4, начинающийся с `4. **Особенности \`refresh\`.**`, и в конец этого пункта (после предложения
|
||||
про `result.missing`) дописать новую строку того же отступа:
|
||||
|
||||
```markdown
|
||||
С v10 у каждой задачи состояния есть `comments` — все комментарии задачи (автор — отображаемое имя, иначе логин; время
|
||||
и текст — как в Jira). Они приходят тем же `search`; если Jira отдала не все, агент дочитывает их через
|
||||
`/issue/{key}/comment`.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: CLAUDE.md**
|
||||
|
||||
В `CLAUDE.md`, раздел «Первая подсистема», заменить строку, начинающуюся с `Последний change —`, на:
|
||||
|
||||
```markdown
|
||||
Последний change — `../jira-hub/openspec/changes/issue-card-modal/` (агент v10: комментарии в состоянии; решения агента — `openspec/changes/agent-v10-comments/design.md`), план — `docs/superpowers/plans/2026-10-15-agent-v10-comments.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Change решений агента**
|
||||
|
||||
Run: `openspec new change agent-v10-comments`
|
||||
Expected: создан каталог `openspec/changes/agent-v10-comments/`.
|
||||
|
||||
Создать `openspec/changes/agent-v10-comments/design.md`:
|
||||
|
||||
```markdown
|
||||
# agent-v10-comments — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/issue-card-modal/` (issue aep/jira-hub#37, aep/jira-agent#27).
|
||||
Агентская часть — решения 1–4 дизайна хаба (поле `comment` в `search`, дочитывание, форма комментария, версия и выкатка)
|
||||
и требование `agent` «Чтение состояния».
|
||||
|
||||
Решения владельца 15.10.2026 (brainstorming jira-hub#37): комментарии задачи нужны в карточке веб-приложения; два change
|
||||
по репозиториям, сначала агент; автор — отображаемое имя, как в Jira над комментарием.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Рулинги R-1…R-5 записаны в `jira-hub/openspec/changes/issue-card-modal/design.md`
|
||||
(раздел «Decisions made without discussion»); здесь — только решения, принятые при исполнении агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
```
|
||||
|
||||
(Таблица пока без строк; решения при исполнении дописываются строками `| R-6 | … |` и далее.)
|
||||
|
||||
- [ ] **Step 6: Весь набор**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/test_folder.py README.md CLAUDE.md openspec/changes/agent-v10-comments
|
||||
git commit -m "chore(agent): версия 10, README и решения агента для комментариев (#27)
|
||||
|
||||
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
## После плана
|
||||
|
||||
- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #27 и на change `issue-card-modal`.
|
||||
- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на
|
||||
main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status`
|
||||
«обновление: ok v9 → v10».
|
||||
1395
docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md
Normal file
1395
docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md
Normal file
File diff suppressed because it is too large
Load Diff
487
docs/superpowers/plans/2026-10-19-projects-agent.md
Normal file
487
docs/superpowers/plans/2026-10-19-projects-agent.md
Normal file
@ -0,0 +1,487 @@
|
||||
# projects — агент v12: доски проектов из параметров команд — план
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** агент v12 берёт доски Jira из параметров команд: `ensure_sprint` — из `board_id` команды, `refresh` — из
|
||||
`boards` (`[{id, type}]`); для досок с `type = null` определяет тип (`GET /rest/agile/1.0/board/{id}`) и возвращает его
|
||||
в результате `boards`, спринты задач собирает со всех scrum-досок; без `board_id`/`boards` работает по
|
||||
`settings.board_id`, как v11.
|
||||
|
||||
**Architecture:** всё в `src/agent.py`, класс `Executor`. Кэш спринтов `_sprints` из одного словаря становится словарём
|
||||
«доска → (имя → спринт)» и по-прежнему сбрасывается в `Executor.run` на каждую команду (а при смене настроек `Runner`
|
||||
пересоздаёт `Executor`). `sprints(board)` читает активные и будущие спринты одной доски; `do_ensure_sprint` берёт доску
|
||||
`p["board_id"]`, иначе `settings.board_id`. В `do_refresh` первым шагом `refresh_boards(p)` определяет типы досок с
|
||||
`type = null` (`board_type(board)`, ошибка HTTP — `None` и строка в логе) и отдаёт список scrum-досок и словарь для
|
||||
результата; `sprint_names(keys, boards)` обходит доски по порядку, последнее найденное имя побеждает.
|
||||
|
||||
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках
|
||||
`tests/fake_jira.py`, `tests/fake_hub.py`.
|
||||
|
||||
**Spec:** change хаба `projects` (aep/jira-hub#61), пока в worktree хаба:
|
||||
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/changes/projects/` —
|
||||
`specs/agent/spec.md` (ADDED «Доски проектов» — основное, MODIFIED «Состав и настройки агента»),
|
||||
`specs/commands/spec.md` (таблица команд: `ensure_sprint.board_id`, `refresh.boards`, результат `boards`),
|
||||
`design.md` (решения 4, 7, 14, 15; «Decisions made without discussion» № 6, 7, 14). Действующая спека агента —
|
||||
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/specs/agent/spec.md`. Решения агентской
|
||||
части — `openspec/changes/agent-v12-project-boards/design.md` (создаётся в Task 3).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/projects`, ветка `feat/35-projects`
|
||||
(aep/jira-agent#35). Все команды — из корня worktree; основные checkout'ы `jira-agent` и `jira-hub` не трогать.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+
|
||||
(`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`. Для кортежей в аннотациях — `typing.Tuple`.
|
||||
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь
|
||||
набор (до плана — 262 теста; набор идёт ≈2 минуты, прогон всего набора — в фоне).
|
||||
- `AGENT_VERSION` SHALL быть 12.
|
||||
- `ensure_sprint` SHALL брать доску из `board_id` команды; нет `board_id` (или он `null`) — из `settings.board_id`, как v11.
|
||||
- `refresh` SHALL брать доски из `boards` (`[{id, type}]`, `type` — `"scrum"`, `"kanban"` или `null`). Нет ключа
|
||||
`boards` (хаб до проектов) — одна доска `settings.board_id`, как v11, результат без `boards`.
|
||||
- Для каждой доски с `type = null` — ровно один `GET /rest/agile/1.0/board/{id}`; тип из ответа (`type`) — `"scrum"` или
|
||||
`"kanban"`, иначе `null`; ошибка запроса — `null`, строка в логе с номером доски и кодом ответа, `refresh` продолжается.
|
||||
- Результат `refresh` — `boards: {"<id>": "scrum" | "kanban" | null}` только для досок, чей тип агент запрашивал; ключ —
|
||||
строка.
|
||||
- Имена спринтов — со всех досок типа `scrum` (известного или только что определённого) в порядке `boards`; `kanban` и
|
||||
`null` пропускаются; задача в спринтах двух досок получает последнее найденное имя (как в v11).
|
||||
- `ensure_sprint` на доске без спринтов (Jira ответила ошибкой на список спринтов) — `failed` с текстом Jira.
|
||||
- Коммиты на русском с `(#35)`, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Хаб с проектами прислал `boards: []` (ни у одного проекта нет доски), а в его настройках остался `board_id = 4216`
|
||||
(решение 14 хаба) — агент не читает спринты доски из настроек, спринты задач — `null` (Task 2,
|
||||
`test_empty_boards_no_settings_fallback`).
|
||||
2. `GET /board/{id}` ответил 5xx (а не 404) — тип `null`, строка в логе с кодом, `refresh` завершается `done`, состояние
|
||||
уходит в хаб (Task 2, `test_board_type_5xx_logged_refresh_continues`).
|
||||
3. Одна и та же доска с `type = null` дважды в `boards` — один запрос типа, один ключ в результате (Task 2,
|
||||
`test_duplicate_null_board_requested_once`).
|
||||
4. `refresh {keys: []}` с доской неизвестного типа — тип всё равно определяется и возвращается (иначе хвост пакета без
|
||||
задач оставил бы тип неизвестным) (Task 2, `test_empty_keys_still_resolves_boards`).
|
||||
5. Доска, известная хабу как `scrum`, перестала отдавать спринты (стала канбаном, удалена) — `refresh` `failed` с текстом
|
||||
Jira, как v11, а не молчаливое обнуление спринтов в зеркале (Task 2, `test_scrum_board_sprints_error_fails_refresh`;
|
||||
решение R-32).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Спринты по доске и `ensure_sprint` с `board_id` из команды
|
||||
|
||||
**Модель исполнителя:** sonnet.
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/fake_jira.py` — `FakeJira.__init__` (новые поля), маршруты Agile в `route` (стр. 352–380)
|
||||
- Modify: `src/agent.py` — `Executor.__init__` и `Executor.run` (стр. 556–564, сброс `_sprints`), `Executor.sprints`
|
||||
(стр. 838–851), `Executor.do_ensure_sprint` (стр. 853–873)
|
||||
- Test: `tests/test_executor_links.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: —
|
||||
- Produces:
|
||||
- `FakeJira.boards: Dict[int, str]` — доска → тип, по умолчанию `{4216: "scrum"}`. `GET /rest/agile/1.0/board/{id}`:
|
||||
есть — 200 `{"id": id, "name": "Доска <id>", "type": <тип>}`, нет — 404 `{"errorMessages": ["Board does not exist"]}`.
|
||||
`GET /rest/agile/1.0/board/{id}/sprint` у доски с типом `"kanban"` — 400
|
||||
`{"errorMessages": ["The board does not support sprints"]}`.
|
||||
- `FakeJira.board_sprints: Dict[int, List[dict]]` — спринты доски; доски нет в словаре — общий `self.sprints` (как
|
||||
раньше, чтобы старые тесты не менялись).
|
||||
- `FakeJira.sprint_members: Dict[int, List[str]]` — id спринта → ключи задач; для такого спринта
|
||||
`GET /sprint/{id}/issue` отдаёт эти ключи (с фильтром по `jql` `key in (…)`) вместо сравнения по имени.
|
||||
- `FakeJira.all_sprints() -> List[dict]` — `self.sprints` + все списки `board_sprints`; по нему ищут спринт
|
||||
`/sprint/{id}/issue` и `GET /rest/agile/1.0/issue/{key}`.
|
||||
- `Executor._sprints: Dict[str, Dict[str, dict]]` — `str(доска)` → имя → спринт; `{}` в `__init__` и в начале `run`.
|
||||
- `Executor.sprints(self, board: Any) -> Dict[str, dict]` — активные и будущие спринты доски `board` (постранично, как
|
||||
v11), с кэшем на команду; ошибку Jira не глотает.
|
||||
|
||||
- [ ] **Step 1: Фейк — доски, спринты по доске, участники спринта**
|
||||
|
||||
Добавить в `FakeJira` поля и маршрут `GET /rest/agile/1.0/board/{id}` из Interfaces (регулярка `^/rest/agile/1\.0/board/(\d+)$`
|
||||
не пересекается с `…/sprint`), 400 для канбан-доски в маршруте `/board/{id}/sprint`, список спринтов по `board_sprints`,
|
||||
`all_sprints()` в маршрутах `/sprint/{id}/issue` и `/agile/1.0/issue/{key}`, `sprint_members` в `GET /sprint/{id}/issue`.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
|
||||
Expected: PASS (фейк по умолчанию ведёт себя как раньше).
|
||||
|
||||
- [ ] **Step 2: Падающие тесты**
|
||||
|
||||
В `tests/test_executor_links.py`, после `test_sprint_without_board`:
|
||||
|
||||
```python
|
||||
def test_sprint_board_from_params(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
|
||||
paths = [r[1] for r in self.fake.requests]
|
||||
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
|
||||
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
|
||||
|
||||
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
|
||||
self.ex.s["board_id"] = None
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||||
|
||||
def test_sprint_unknown_name_names_param_board(self):
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("5120", out["error"])
|
||||
self.assertIn("Спринт K", out["error"])
|
||||
|
||||
def test_sprint_kanban_board_failed_with_jira_text(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 400", out["error"])
|
||||
self.assertIn("does not support sprints", out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
|
||||
|
||||
def test_sprint_backlog_ignores_board(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
|
||||
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
|
||||
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
|
||||
```
|
||||
|
||||
`test_sprint_without_board` (без `board_id` в команде и в настройках) не меняется: ошибка по-прежнему содержит `board_id`.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v`
|
||||
Expected: FAIL `test_sprint_board_from_params` (идёт на доску 4216: «нет спринта «Спринт K»»),
|
||||
`test_sprint_board_from_params_without_settings` («board_id не задан…»), `test_sprint_unknown_name_names_param_board`
|
||||
(в тексте 4216, а не 5120); `test_sprint_kanban_board_failed_with_jira_text` и `test_sprint_backlog_ignores_board` могут
|
||||
пройти уже сейчас — это допустимо, они фиксируют поведение.
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
- `Executor._sprints = {}` в `__init__` и в начале `run` (вместо `None`).
|
||||
- `sprints(self, board: Any) -> Dict[str, dict]` — тело v11, доска из аргумента, кэш `self._sprints[str(board)]`;
|
||||
docstring: «Имя → спринт (active и future) доски board».
|
||||
- `do_ensure_sprint`: `board = p.get("board_id") or self.s.get("board_id")` после ветки `backlog`; нет доски —
|
||||
`failed` «board_id нет ни в команде, ни в настройках хаба — спринты недоступны»; дальше как v11 с `self.sprints(board)`,
|
||||
в тексте «нет спринта …» — номер `board`.
|
||||
- `sprint_names` пока зовёт `self.sprints(self.s["board_id"])` (переделывается в Task 2).
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Весь набор**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/fake_jira.py tests/test_executor_links.py src/agent.py
|
||||
git commit -m "feat(agent): ensure_sprint по доске из команды, кэш спринтов по доскам (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `refresh` с досками проектов: тип доски, спринты со всех scrum-досок, результат `boards`
|
||||
|
||||
**Модель исполнителя:** sonnet.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — импорт `Tuple` из `typing` (стр. 34), новые `Executor.board_type`, `Executor.refresh_boards`
|
||||
(раздел `refresh`, перед `sprint_names`), `Executor.sprint_names` (стр. 1024–1043), `Executor.do_refresh`
|
||||
(стр. 1045–1095)
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 — `Executor.sprints(board)`, `FakeJira.boards`, `FakeJira.board_sprints`, `FakeJira.sprint_members`.
|
||||
- Produces:
|
||||
- `Executor.board_type(self, board: Any) -> Optional[str]` — `GET /rest/agile/1.0/board/{board}`; `type` из ответа,
|
||||
если он `"scrum"` или `"kanban"` (точное совпадение), иначе `None` и в лог
|
||||
`"refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)`; `HttpError` любого кода — `None` и в лог
|
||||
`"refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code)`; `ConnectionError` не ловит.
|
||||
- `Executor.refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]` — (scrum-доски в
|
||||
порядке `boards` без повторов по `str(id)`, словарь для результата). Нет ключа `boards` — `([settings.board_id]` или
|
||||
`[]`, `None)`. Есть — для каждой доски с `type is None` один `board_type`; словарь — запрошенные доски
|
||||
(`str(id)` → тип), пустой словарь возвращается как `None`. Элемент без `id` пропускается.
|
||||
- `Executor.sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]` — как v11, но по каждой доске
|
||||
`boards` по порядку (`for board in boards: for name, sp in self.sprints(board).items(): …`); пустые `keys` или
|
||||
`boards` — `{}`.
|
||||
- `do_refresh`: первым действием `scrum, types = self.refresh_boards(p)` (до поиска и до раннего выхода по пустым
|
||||
`keys`); `names = self.sprint_names(keys, scrum)`; если `types is not None` — `result["boards"] = types` (и в раннем
|
||||
выходе по пустым `keys`).
|
||||
|
||||
- [ ] **Step 1: Падающие тесты**
|
||||
|
||||
В `tests/test_refresh.py` поменять вызов в `test_sprint_names_paginated` на
|
||||
`self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])` и добавить:
|
||||
|
||||
```python
|
||||
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
||||
|
||||
def refresh(self, **params):
|
||||
return self.ex.run({"id": 5, "type": "refresh", "params": params})
|
||||
|
||||
def logged(self, fn):
|
||||
logs, orig = [], agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
return fn(), logs
|
||||
finally:
|
||||
agent.log = orig
|
||||
|
||||
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
paths = [r[1] for r in self.fake.requests]
|
||||
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
|
||||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_board_not_found(self): # сценарий «Доска не найдена»
|
||||
self.fake.add("AURORA-1")
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"9999": None})
|
||||
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
|
||||
self.assertEqual(len(self.hub.states), 1)
|
||||
|
||||
def test_new_scrum_board_sprints_read(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
self.fake.add("AURORA-1", sprint="Спринт K")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||||
|
||||
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL)
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_known_boards_no_type_requests(self):
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_empty_boards_no_settings_fallback(self):
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
|
||||
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
|
||||
|
||||
def test_board_type_5xx_logged_refresh_continues(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": None})
|
||||
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
|
||||
|
||||
def test_board_other_type_is_null(self):
|
||||
self.fake.boards[7000] = "simple"
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
|
||||
self.assertEqual(out["result"]["boards"], {"7000": None})
|
||||
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
|
||||
|
||||
def test_duplicate_null_board_requested_once(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
|
||||
|
||||
def test_empty_keys_still_resolves_boards(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
|
||||
|
||||
def test_last_found_sprint_wins_across_boards(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
|
||||
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
|
||||
self.refresh(jql=self.JQL, boards=both)
|
||||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
|
||||
self.refresh(jql=self.JQL, boards=both[::-1])
|
||||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_scrum_board_sprints_error_fails_refresh(self): # R-32
|
||||
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
|
||||
self.fake.add("AURORA-1")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}])
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 400", out["error"])
|
||||
self.assertEqual(self.hub.states, [])
|
||||
```
|
||||
|
||||
`FakeJira.search_keys` на любую JQL без `key in` и без `reporter` отдаёт задачи с меткой `AWPCA` — `self.JQL` подходит.
|
||||
В `test_board_type_5xx_logged_refresh_continues` 500 достаётся запросу типа доски потому, что `refresh_boards` — первый
|
||||
вызов Jira в `do_refresh` (R-30).
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
|
||||
Expected: FAIL — `KeyError: 'boards'` в результате, `TypeError` у `test_sprint_names_paginated` (лишний аргумент) и
|
||||
запросы к доске 4216 там, где их быть не должно; `test_old_hub_without_boards` проходит уже сейчас.
|
||||
|
||||
- [ ] **Step 2: Реализация**
|
||||
|
||||
`board_type`, `refresh_boards`, `sprint_names(keys, boards)` и правка `do_refresh` — по Interfaces. Комментарий R-6 у
|
||||
`sprint_names` сохранить.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_refresh tests.test_executor_links -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Весь набор**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/test_refresh.py
|
||||
git commit -m "feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Версия 12, документация, change решений агента
|
||||
|
||||
**Модель исполнителя:** haiku (готовый текст).
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
|
||||
- Modify: `tests/test_folder.py:185-186` (`test_version`)
|
||||
- Modify: `README.md` — абзац «Остальные настройки…» (стр. 27), строки `ensure_sprint` и `refresh` таблицы команд
|
||||
(стр. 173, 175), пункт «Версия» (стр. 193–200), пункт 3 «Известных ограничений» (стр. 237–238)
|
||||
- Modify: `ARCHITECTURE.md` — строка «Исполнитель» таблицы разделов (стр. 14)
|
||||
- Modify: `CLAUDE.md` — раздел «Первая подсистема», строка «Последний change —»
|
||||
- Create: `openspec/changes/agent-v12-project-boards/design.md` (через `openspec new change agent-v12-project-boards`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1–2.
|
||||
- Produces: `AGENT_VERSION = 12`.
|
||||
|
||||
- [ ] **Step 1: Падающий тест версии**
|
||||
|
||||
В `tests/test_folder.py`, `test_version`: `assertEqual(agent.AGENT_VERSION, 11)` → `assertEqual(agent.AGENT_VERSION, 12)`.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||||
Expected: FAIL — `AssertionError: 11 != 12`.
|
||||
|
||||
- [ ] **Step 2: Версия**
|
||||
|
||||
В `src/agent.py` `AGENT_VERSION = 11` → `AGENT_VERSION = 12`.
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: README**
|
||||
|
||||
- Стр. 27: `(id полей, типы задач, карта переходов, типы связей, доска, темп)` → `(id полей, типы задач, карта переходов,
|
||||
типы связей, темп)`, а после предложения дописать: «Доски Jira (с v12) приходят в параметрах команд: `ensure_sprint` —
|
||||
`board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд старого хаба.»
|
||||
- Строка `ensure_sprint`: `переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и
|
||||
будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira`.
|
||||
- Строка `refresh`: дописать в конец ячейки `; доски с неизвестным типом (`boards`, `type = null`) — запрос
|
||||
`/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок`.
|
||||
- Пункт «Версия»: `(сейчас 11)` → `(сейчас 12)`; после строки «Агент v10 на `ensure_attachment` …» дописать строкой того
|
||||
же отступа:
|
||||
|
||||
```markdown
|
||||
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
|
||||
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
|
||||
выкатывается только после v12 (design хаба `projects`, решение 14).
|
||||
```
|
||||
|
||||
- Пункт 3 ограничений: `**Спринт в `refresh` — только активные и будущие спринты доски.**` → `**Спринт в `refresh` —
|
||||
только активные и будущие спринты scrum-досок.**`; после «У задач закрытых спринтов `sprint = null`.» дописать: «Задачи
|
||||
канбан-досок и досок неизвестного типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по
|
||||
списку `boards`. Если scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом
|
||||
Jira (R-32).»
|
||||
|
||||
- [ ] **Step 4: ARCHITECTURE.md и CLAUDE.md**
|
||||
|
||||
- `ARCHITECTURE.md`, строка «Исполнитель», колонка «Ответственность» — дописать в конец: `; доски — из параметров команд
|
||||
(`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной`.
|
||||
- `CLAUDE.md`, строку `Последний change — …` заменить на:
|
||||
|
||||
```markdown
|
||||
Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Change решений агента**
|
||||
|
||||
Run: `openspec new change agent-v12-project-boards`
|
||||
Expected: создан каталог `openspec/changes/agent-v12-project-boards/`.
|
||||
|
||||
Создать `openspec/changes/agent-v12-project-boards/design.md`; таблица — те же строки R-30…R-35, что в разделе
|
||||
«Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими
|
||||
номерами, начиная с R-36:
|
||||
|
||||
```markdown
|
||||
# agent-v12-project-boards — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35).
|
||||
Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки
|
||||
агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»;
|
||||
для агента — № 6, 7, 14); здесь — только решения агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
<строки R-30…R-35 из плана>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Весь набор и синтаксис 3.9**
|
||||
|
||||
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
Expected: `OK` (262 + новые из Task 1–2).
|
||||
|
||||
Run: `/usr/bin/python3 -m unittest tests.test_py39 -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/test_folder.py README.md ARCHITECTURE.md CLAUDE.md openspec/changes/agent-v12-project-boards
|
||||
git commit -m "chore(agent): версия 12, README и решения агента для досок проектов (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Нумерация рулингов агентской части — с R-30, чтобы не пересечься с рулингами исполнения хабовой части change.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` |
|
||||
| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) |
|
||||
| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба |
|
||||
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
|
||||
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
|
||||
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
|
||||
|
||||
## После плана
|
||||
|
||||
- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #35 и на change `projects` хаба. Агент
|
||||
сливается и публикуется первым (design хаба, решения 14 и 15).
|
||||
- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на
|
||||
main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status`
|
||||
«обновление: ok v11 → v12». Затем — выкатка хаба (шаги 2–4 решения 14 хаба).
|
||||
92
docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md
Normal file
92
docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Вложения из Jira по запросу (агент v14) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** агент v14 в `refresh` присылает список вложений задач (без содержимого) и выполняет новую команду
|
||||
`fetch_attachment`: скачивает файл вложения из Jira и передаёт хабу `POST /agent/files`.
|
||||
|
||||
**Architecture:** список — поле `attachment` в `search` и `Executor.attachments_of(raw)` в `to_state`; скачивание —
|
||||
`JiraClient.download` (байты, предел, без перенаправлений на другой хост) и `Executor.do_fetch_attachment`; отправка —
|
||||
`HubClient.put_file` (работает и через `FolderTransport`). Python 3.9, только stdlib.
|
||||
|
||||
**Tech Stack:** Python 3.9 (`/usr/bin/python3`), stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`.
|
||||
|
||||
**Spec:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/attach-fetch/openspec/changes/attachment-fetch/` —
|
||||
`design.md` (решения 1, 4–6, 8, 17, 18), `specs/agent/spec.md` («Чтение состояния», «Скачивание вложения»),
|
||||
`specs/commands/spec.md` («Скачивание вложения по запросу»), `specs/hub-api/spec.md` (`POST /agent/files`). Решения агента —
|
||||
`openspec/changes/agent-v14-attachment-fetch/design.md` этого репозитория (таблица `R-n`).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/attach-fetch`, ветка `feat/43-attachment-fetch`
|
||||
(aep/jira-agent#43). Все команды — из корня worktree.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `AGENT_VERSION = 14`.
|
||||
- Python 3.9 stdlib; без `match`, без `X | Y` в аннотациях (`tests/test_py39.py`).
|
||||
- Предел — `MAX_ATTACHMENT = 10 * 1024 * 1024` (10 485 760 байт).
|
||||
- Адрес содержимого — `<base_url>/secure/attachment/<id>/<urllib.parse.quote(filename, safe="")>`; `content` из ответа Jira не используется.
|
||||
- Таймаут скачивания — `max(self.timeout, 120)`.
|
||||
- Элемент `attachments`: `{id, filename, size, mime_type, author, created}` (`author` — `displayName`, иначе `name`, иначе `None`).
|
||||
- Тексты ошибок (точно): «вложение #<id> удалено из <key> в Jira»; «файл больше 10 МБ (<размер>) — скачайте в Jira» (неизвестный размер — «больше 10,0 МБ»); «Jira перенаправила скачивание на <хост>»; «файл пришёл не целиком (<получено> из <размер>)». Размер — «N Б» / «N,N КБ» / «N,N МБ» по 1024, запятая.
|
||||
- Содержимое файла не попадает в лог, журнал и текст ошибок.
|
||||
- Каждый коммит — с `(#43)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
- Решения вне спеки и плана — в таблицу `openspec/changes/agent-v14-attachment-fetch/design.md`; находки ревью задачи n — `T<n>.<k>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Перенаправление 302 на тот же хост (Jira иногда так отдаёт файл) — проходит; на другой хост или схему — `failed` без запроса туда (тесты в Task 2).
|
||||
2. Вложение без `size` в Jira и содержимое больше предела — чтение обрывается, хабу ничего не уходит (тест в Task 3).
|
||||
3. Пустое вложение (0 байт) — `done`, хаб получил пустой `content` (тест в Task 3).
|
||||
4. Хаб недоступен на `POST /agent/files` — `failed` с `retryable`, как прочие обрывы связи с хабом (тест в Task 3).
|
||||
5. Кириллица и пробелы в имени — путь скачивания в URL-кодировке, фейк Jira находит файл (тест в Task 3).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Список вложений в состоянии и версия 14
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `AGENT_VERSION = 14`, `STATE_FIELDS` + `"attachment"`, `Executor.attachment_state(a) -> dict` (статический), `Executor.attachments_of(raw) -> List[dict]`, `to_state` добавляет `attachments`
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `attachments_of(raw)` — вложения в порядке Jira, без `id` — отброшено с `log` (ключ задачи); нет поля `attachment` — `[]`.
|
||||
|
||||
- [ ] **Step 1: Тесты** — `test_refresh_attachments_in_state` (сценарий «Вложения в состоянии» из дельты `agent`: два вложения, `author = "Потапова Анна"`, `size = 240000`, `mime_type = "image/png"`; число вызовов `search` прежнее; к содержимому обращений нет — фейк считает GET `/secure/attachment/`), `test_refresh_attachment_without_id_dropped`, `test_refresh_no_attachments_empty_list`.
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация** по сигнатурам.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент v14: список вложений в состоянии refresh (#43)`.
|
||||
|
||||
### Task 2: Скачивание байтов из Jira и файл хабу
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Http.fetch_bytes(path, limit, timeout) -> bytes` (GET, без разбора JSON; перенаправления — свой `HTTPRedirectHandler`: тот же схема+хост+порт, что у `base`, — идти; иначе `RedirectAway(host)`); `JiraClient.download(path, limit) -> bytes` (через `_retry`, счётчик вызовов); класс `RedirectAway(Exception)` с полем `host`; `TooBig(Exception)`; `HubClient.put_file(data: bytes) -> dict` (`POST /agent/files {content: base64}`)
|
||||
- Modify: `tests/fake_jira.py` — `GET /secure/attachment/<id>/<имя>` отдаёт `data` вложения (имя сверяется после раскодирования), счётчик `self.content_gets`; `self.redirect_content = None` (адрес для 302); `self.hide_size = set()` (id без `size` в JSON)
|
||||
- Modify: `tests/fake_hub.py` — `POST /agent/files` кладёт в `self.files`, отвечает `{file, size}`; `self.reject_files = None` (код ответа или `"drop"`)
|
||||
- Test: `tests/test_clients.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `JiraClient.download(path: str, limit: int) -> bytes` — читает не больше `limit + 1` байт; если прочитано больше `limit` — `TooBig`; 429/503 — повтор как у `call`; `RedirectAway` и `TooBig` наружу.
|
||||
|
||||
- [ ] **Step 1: Тесты** — `test_download_bytes` (тело как есть, `calls` +1, заголовок `Authorization` ушёл); `test_download_redirect_same_host` (302 на тот же хост → байты); `test_download_redirect_other_host` (302 на `http://127.0.0.2:<порт>/x` → `RedirectAway`, второй сервер запроса не получил); `test_download_limit` (limit=10, тело 20 байт → `TooBig`); `test_download_429_retry`; `test_put_file_http` и `test_put_file_folder` (через `FolderTransport` и `tests/fake_relay.py`).
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_clients -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация.** Обработчик перенаправлений — `urllib.request.build_opener(_SameHostRedirect(base), HTTPSHandler(context=ctx))`; `urlopen` в `Http.send` не трогать.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: скачивание байтов из Jira без чужих перенаправлений, файл хабу (#43)`.
|
||||
|
||||
### Task 3: Команда `fetch_attachment`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.do_fetch_attachment(p, cmd) -> dict`, `_fmt_size(n: Optional[int]) -> str`; ARCHITECTURE.md (раздел «Исполнитель», `JiraClient.download`, `HubClient.put_file`), `CLAUDE.md` (граница `Executor` знает хаб и в `fetch_attachment`; последний change)
|
||||
- Test: `tests/test_executor_attachments.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `JiraClient.download`, `RedirectAway`, `TooBig`, `HubClient.put_file` (Task 2); `self._hub(fn, *args)` для вызова хаба.
|
||||
- Produces: результат `done` — `{"attachment_id", "file", "size"}`; `file` и `size` — из ответа хаба.
|
||||
|
||||
- [ ] **Step 1: Тесты** — `test_fetch_screenshot` (сценарий «Скачивание скриншота»: имя «скрин 1.png», фейк Jira отдал файл, хаб получил те же байты, результат `done` с sha256 и размером, в журнале агента нет содержимого); `test_fetch_deleted` («вложение #31337 удалено из AURORA-1650 в Jira», содержимое не запрашивалось); `test_fetch_too_large_known` (size 25 000 000 в JSON → «файл больше 10 МБ (23,8 МБ) — скачайте в Jira», `content_gets == 0`); `test_fetch_too_large_unknown` (`hide_size`, содержимое 12 МБ → «файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira», хаб ничего не получил); `test_fetch_redirect_away` («Jira перенаправила скачивание на 127.0.0.2»); `test_fetch_truncated` (фейк отдаёт меньше байт, чем `size` → «файл пришёл не целиком (N из M)»); `test_fetch_empty` (0 байт → `done`, `size = 0`); `test_fetch_hub_down` (`reject_files = "drop"` → `failed`, `retryable`, `peer = "хабом"`); `test_fetch_hub_422` (ошибка с «хаб: »).
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_attachments -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация** по контракту `commands` «Скачивание вложения по запросу» (шаги 1–5); лог — `log(" вложение #%s «%s» (%s) ← %s" % (...))`.
|
||||
- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент v14: команда fetch_attachment (#43)`.
|
||||
141
docs/superpowers/plans/2026-10-22-change-history-agent.md
Normal file
141
docs/superpowers/plans/2026-10-22-change-history-agent.md
Normal file
@ -0,0 +1,141 @@
|
||||
# Журнал изменений задач (агент v13) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** агент v13 по параметру `history` команды `refresh` читает журнал изменений (changelog) Jira — по изменившимся
|
||||
задачам (`since`) и/или стартовой JQL (`jql`) — и отправляет его хабу `POST /agent/history`; итог — в результате
|
||||
`refresh`.
|
||||
|
||||
**Architecture:** шаг журнала — в конце `Executor.do_refresh`, после отправки всех пачек состояния; разбор журнала задачи
|
||||
в элементы — чистая функция; ошибки ответа (не 2xx) Jira и хаба на этом шаге деградируют в `history.error` /
|
||||
`history.backfill_error`, обрыв связи — как во всём `refresh`. Python 3.9, только stdlib.
|
||||
|
||||
**Tech Stack:** Python 3.9 (`/usr/bin/python3`), stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`.
|
||||
|
||||
**Spec:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/history/openspec/changes/change-history/` —
|
||||
`design.md` (решения 7–11, 17), `specs/agent/spec.md` («Журнал изменений»), `specs/commands/spec.md` (форма
|
||||
`history`), `specs/hub-api/spec.md` («Приём журнала изменений»). Решения агента — `openspec/changes/agent-v13-change-history/design.md`
|
||||
этого репозитория (таблица `R-n`).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/history`, ветка `feat/39-change-history`
|
||||
(aep/jira-agent#39). Все команды — из корня worktree.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `AGENT_VERSION = 13`.
|
||||
- Python 3.9 stdlib: без `datetime.fromisoformat` для времени Jira с `+0300` и `Z` (3.9 их не разбирает) — свой разбор; без `match`, без `X | Y` в аннотациях (`tests/test_py39.py`).
|
||||
- Запросы журнала: `search` `jql = key in (...)` (≤ 100 ключей), `fields = key`, `expand = changelog`, `maxResults = 100`; стартовая — `search` по `history.jql` с теми же `fields`/`expand`, постранично; дочитывание — `GET /rest/api/2/issue/{key}` с `fields = key`, `expand = changelog`; поиск id поля — `GET /rest/api/2/field`, не больше одного раза за `refresh`.
|
||||
- Пачки `POST /agent/history` — до 500 элементов, тело `{command_id, changes}`.
|
||||
- Результат: `history = {issues, changes, incomplete}` + `error` / `backfill_error` по спеке; без `history` в параметрах — ключа нет.
|
||||
- Лог — функция `log(...)` агента, тексты по-русски.
|
||||
- Каждый коммит — с `(#39)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
- Решения, не записанные в спеке и плане, — в таблицу `openspec/changes/agent-v13-change-history/design.md` (маркер `R-n` — продолжение нумерации хабовой фичи, дата, решение, обоснование, цена ошибки). Находки ревью задачи n — `T<n>.<k>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Время Jira в формах `2026-10-22T13:05:11.000+0300`, `…+03:00`, `…Z`, без дробной части; `since` от хаба с `Z` — сравнение моментов верное на границе (`updated == since` входит) (тесты в Task 1).
|
||||
2. Запись журнала, в которой одни элементы годные, а другой с пустым `field`, — годные отправляются, плохой отброшен с логом, хаб не получает 422 (тест в Task 1).
|
||||
3. Хаб ответил 422 на пачку журнала — `refresh` `done`, `history.error` с префиксом «хаб: », повтора команды нет; хаб недоступен (`ConnectionError`) — `refresh` `failed` с `retryable` (тесты в Task 2).
|
||||
4. `history = {since, jql}` и задача попала и в изменившиеся, и в стартовую выборку — её журнал читается и отправляется один раз (тест в Task 2).
|
||||
5. Соседи, дочитанные в этом `refresh`, с `updated >= since` — их журнал тоже читается (тест в Task 2).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Фейк журнала Jira и разбор журнала задачи
|
||||
|
||||
**Модель исполнителя:** sonnet.
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/fake_jira.py` — журнал задач, `expand=changelog`, `GET /rest/api/2/field`, заданные результаты JQL
|
||||
- Modify: `src/agent.py` — `_jira_time`, `Executor.changelog_items`
|
||||
- Test: `tests/test_history.py` (новый)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (`tests/fake_jira.py`):
|
||||
- `self.changelogs = {}` — ключ → список записей Jira `{"id", "author": {"name", "displayName"}, "created", "items": [{"field", "fieldtype", "fieldId"?, "from", "fromString", "to", "toString"}]}`;
|
||||
- `add_history(key, created, items, hid=None, name="petrov", display="Петров Пётр") -> str` (id записи);
|
||||
- `self.changelog_cap = None` — сколько записей отдаёт `search`/`GET /issue` (`None` — все; `total` — всегда полное число);
|
||||
- `self.issue_changelog_cap = None` — то же только для `GET /issue/{key}?expand=changelog`;
|
||||
- `self.jql_results = {}` — JQL → список ключей (проверяется в `search_keys` первым);
|
||||
- `self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, …]` — ответ `GET /rest/api/2/field`;
|
||||
- `expand=changelog` в `search` и `GET /issue/{key}` добавляет к задаче `changelog: {startAt: 0, maxResults, total, histories}`.
|
||||
- Produces (`src/agent.py`):
|
||||
- `_jira_time(value) -> Optional[datetime]` — aware datetime или `None`; формы — Review Focus 1.
|
||||
- `Executor.changelog_items(self, key: str, histories: List[dict], since: Optional[datetime], field_id_of: Callable[[str], Optional[str]]) -> List[dict]` — элементы для хаба по спеке `agent` «Журнал изменений» (форма элемента, отбрасывание записей без `id`/с пустым `created`, элементов с пустым `field`, отсечение `created < since` при `since`); `field_id_of(name)` вызывается только для `fieldtype == "custom"` без `fieldId`; нестроковые `from`/`to` — `str(...)`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
# tests/test_history.py (unittest, как test_refresh.py)
|
||||
def test_jira_time_forms(self):
|
||||
for raw in ("2026-10-22T13:05:11.000+0300", "2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11Z", "2026-10-22T10:05:11.000Z"):
|
||||
self.assertEqual(agent._jira_time(raw), datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc))
|
||||
self.assertIsNone(agent._jira_time("мусор")); self.assertIsNone(agent._jira_time(None))
|
||||
|
||||
def test_changelog_items_since_and_shape(self):
|
||||
histories = [
|
||||
{"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300",
|
||||
"items": [{"field": "status", "fieldtype": "jira", "from": "1", "fromString": "Открыта", "to": "3", "toString": "В работе"}]},
|
||||
{"id": "2", "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300",
|
||||
"items": [{"field": "status", "fieldtype": "jira", "fieldId": "status", "from": "3", "fromString": "В работе", "to": "10001", "toString": "Готово"},
|
||||
{"field": "resolution", "fieldtype": "jira", "from": None, "fromString": None, "to": "1", "toString": "Готово"}]}]
|
||||
items = self.ex.changelog_items("X", histories, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
|
||||
self.assertEqual([(i["history_id"], i["seq"]) for i in items], [("2", 0), ("2", 1)])
|
||||
self.assertEqual(items[0], {"history_id": "2", "seq": 0, "key": "X", "created": "2026-10-22T12:10:00.000+0300",
|
||||
"author": "ivanov", "field_id": "status", "field_name": "status", "from": "3",
|
||||
"from_string": "В работе", "to": "10001", "to_string": "Готово"})
|
||||
self.assertEqual(items[1]["field_id"], "resolution")
|
||||
|
||||
def test_changelog_items_custom_field_lookup(self): # fieldtype custom без fieldId → field_id_of("Плановое окончание") = "customfield_10201"
|
||||
def test_changelog_items_drops_bad(self): # запись без id, запись с created "", элемент с field "" — отброшены, остальные на месте, строки в логе
|
||||
def test_changelog_items_no_since(self): # since=None — все записи
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run** `/usr/bin/python3 -m unittest tests.test_history -v` — FAIL
|
||||
- [ ] **Step 3: Implement** фейк и функции.
|
||||
- [ ] **Step 4: Run** весь набор (Global Constraints) — зелёный
|
||||
- [ ] **Step 5: Commit** — `Разбор журнала изменений Jira и фейк журнала (#39)`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Шаг журнала в `refresh`, `POST /agent/history`, версия 13
|
||||
|
||||
**Модель исполнителя:** sonnet.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `AGENT_VERSION = 13`; `HubClient.history(payload) -> dict`; `Executor.refresh_history(self, p: dict, cmd: dict, fetched: List[dict]) -> Optional[dict]`; вызов из `do_refresh` после отправки пачек, `result["history"]`, если не `None`
|
||||
- Modify: `tests/fake_hub.py` — маршрут `POST /agent/history`: копит тела в `self.histories`, `self.reject_history = None | (код, тело)` для ответа ошибкой
|
||||
- Modify: `ARCHITECTURE.md` (поток журнала в `refresh`), `README.md` (если перечисляет маршруты хаба/возможности версии)
|
||||
- Test: `tests/test_history.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_jira_time`, `Executor.changelog_items` (Task 1); `self.search_all`-подобная пагинация с `expand`.
|
||||
- Produces: `refresh_history` — `None`, если `"history"` нет в `p` или не словарь; иначе словарь итога. Порядок: (1) регулярная часть по `p["history"]["since"]` (если есть) над `fetched` = все сырые задачи этого `refresh` (база + соседи); (2) стартовая по `p["history"]["jql"]` (если есть), пропуская ключи, уже прочитанные в (1); (3) поиск id поля — лениво, один `GET /field` на `refresh`; (4) дочитывание неполного журнала; (5) отправка пачками по 500. `HttpError` от Jira в (1), (3), (4) или любой `HttpError` хаба в (5) → `error` (текст `"HTTP <код> <текст[:500]>"`, у хаба — `"хаб: …"`), отправленное до ошибки учитывается в `issues`/`changes`; `HttpError` в `search` стартовой части → `backfill_error`, остальное продолжается. `ConnectionError` не ловится (хаба — через `self._hub`, станет `HubError`, как сейчас).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — сценарии спеки `agent` «Журнал изменений» (все восемь) и Review Focus 3–5:
|
||||
|
||||
```python
|
||||
def test_nothing_changed_no_changelog_calls(self):
|
||||
# 3 задачи, updated "2026-10-01…"; refresh {jql: base, history: {since: "2026-10-22T08:45:00Z"}}
|
||||
out = self.ex.run({"id": 1, "type": "refresh", "params": {...}})
|
||||
self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []})
|
||||
self.assertFalse([q for q in self.fake.queries if q.get("expand") == "changelog"])
|
||||
|
||||
def test_two_changed_one_search(self): # X, Y updated после since → один search key in (X,Y) expand=changelog; 3 элемента в hub.histories
|
||||
def test_backfill_by_jql(self): # fake.jql_results[history_jql] = 12 ключей → журнал целиком; запрос по history.jql
|
||||
def test_since_and_jql_dedup(self): # ключ в обеих частях — один раз в отправке
|
||||
def test_neighbor_changed_included(self): # сосед, дочитанный в этом refresh, с updated после since — его журнал отправлен
|
||||
def test_jira_500_degrades(self): # fail_next=(500) на search с expand → out["status"] == "done", history.error начинается с "HTTP 500", нет "retryable"
|
||||
def test_backfill_400(self): # 400 на history.jql, X изменилась → журнал X отправлен, backfill_error, нет error
|
||||
def test_incomplete(self): # changelog_cap=issue_changelog_cap=100 при 250 записях → incomplete ["X"], в логе "X: получено 100 из 250"
|
||||
def test_hub_422_degrades(self): # hub.reject_history=(422, …) → done, history.error начинается с "хаб: "
|
||||
def test_hub_down_fails(self): # хаб остановлен перед шагом журнала → failed, retryable
|
||||
def test_batches_of_500(self): # 600 элементов → две пачки 500 и 100
|
||||
def test_no_history_param(self): # нет ключа history в результате, нет запросов с expand
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run** `/usr/bin/python3 -m unittest tests.test_history -v` — FAIL
|
||||
- [ ] **Step 3: Implement**, `AGENT_VERSION = 13`, документация.
|
||||
- [ ] **Step 4: Run** весь набор (Global Constraints) — зелёный; `tests/test_py39.py` зелёный
|
||||
- [ ] **Step 5: Commit** — `Агент v13: журнал изменений в refresh (#39)`
|
||||
673
docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md
Normal file
673
docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md
Normal file
@ -0,0 +1,673 @@
|
||||
# Спринты, ранг и оценки (агент v15) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** агент v15 в каждом `refresh` присылает хабу ранг, оценку и все спринты задач, данные досок (колонки,
|
||||
kanban-бэклог, все спринты scrum-доски) и выполняет новые команды `ensure_sprint_meta`, `ensure_sprint_state`,
|
||||
`ensure_rank` и поле `story_points` в `ensure_fields`.
|
||||
|
||||
**Architecture:** всё в `src/agent.py`, класс `Executor`. Поля Jira (ранг, спринт, оценка) ищутся по `GET /rest/api/2/field`
|
||||
(кэш на экземпляр `Executor`, перекрытие `field_ids`) и добавляются в `fields` поиска `refresh`; данные досок читаются
|
||||
после определения типов досок (`refresh_boards`) и уходят с первой пачкой `POST /agent/state`. Новые команды — методы
|
||||
`do_<тип>` по образцу `do_ensure_sprint` / `do_ensure_link` («проверь, потом сделай», `self.gap(<тип>)` между записями).
|
||||
|
||||
**Tech Stack:** Python 3.9 (`/usr/bin/python3`), только stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`.
|
||||
|
||||
**Spec:** `../sdlc-hub/openspec/changes/sprints-backlog/` (полный путь
|
||||
`/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints/openspec/changes/sprints-backlog/`: `design.md`
|
||||
решения 10–13, 18–20, 24 и «Контракт данных»; `specs/agent/spec.md` «Спринты, ранг и оценки»; `specs/commands/spec.md`
|
||||
таблица типов и абзацы о командах спринтов; `specs/hub-api/spec.md` «Приём досок и спринтов») и
|
||||
`openspec/changes/agent-v15-sprints/design.md` этого репозитория (решения 1–9, R-60…R-63).
|
||||
|
||||
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/v15-sprints`, ветка `feat/49-v15-sprints`
|
||||
(aep/jira-agent#49). Все команды — из корня worktree. Хабовая часть — отдельный план в `sdlc-hub`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `AGENT_VERSION = 15`.
|
||||
- Python 3.9.6, только stdlib; без `match`, без `X | Y` в аннотациях функций (`tests/test_py39.py`).
|
||||
- Поиск полей (design агента 1): ранг — `schema.custom == "com.pyxis.greenhopper.jira:gh-lexo-rank"`; спринт —
|
||||
`schema.custom == "com.pyxis.greenhopper.jira:gh-sprint"`; оценка — имя «Story Points» без учёта регистра и пробелов
|
||||
по краям; несколько кандидатов — первый по ключу `(len(id), id)`. Перекрытие — `field_ids.rank`, `field_ids.sprint`,
|
||||
`field_ids.story_points` настроек хаба.
|
||||
- Поля задачи в состоянии: `rank` (строка или `None`), `story_points` (`int`/`float`, не `bool`, иначе `None`),
|
||||
`sprint_ids` (список целых, без повторов, порядок Jira). Поле не найдено — ключа у задач нет (R-64).
|
||||
- Элемент `boards`: `{"id", "type", "columns": [{"name", "statuses": [имя статуса]}] | None, "kanban_backlog": True | False | None,
|
||||
"sprints": [{"id", "name", "state", "start_date", "end_date", "complete_date", "goal"}]}`; даты — строки Jira как есть.
|
||||
`boards` — только в первой пачке; при пустой выборке — одна пачка `{issues: [], links: [], neighbor_keys: [], page: 1, last_page: True, boards}`.
|
||||
- Даты команд: `ГГГГ-ММ-ДД` → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` (начало) и `ГГГГ-ММ-ДДT18:00:00.000<смещение>` (конец),
|
||||
смещение — `datetime.now().astimezone().strftime("%z")` (вид `+0300`, R-68); сравнение с Jira — `_date()` (первые 10 символов).
|
||||
- Результаты: `ensure_sprint_meta` — `{"sprint_id", "created"}`; `ensure_sprint_state` — `{"sprint_id", "moved": [ключ…]}`;
|
||||
`ensure_rank` — `{"changed"}`; `ensure_fields` — как раньше, `"story_points"` в `changed`.
|
||||
- Тексты ошибок (точно): «поле оценки не найдено в Jira: задайте field_ids.story_points»; «поле ранга не найдено»;
|
||||
«спринт закрыт»; «спринт не начат»; «имя занято»; «у спринта нет дат: задайте ensure_sprint_meta»;
|
||||
«не удалось прочитать колонки доски»; «несколько спринтов с именем «<имя>»: <id>, <id>».
|
||||
- Числа оценок равны, если `abs(a - b) < 1e-9`; ранги сравниваются как строки.
|
||||
- Каждый коммит — русский заголовок с `(#49)` в конце первой строки и последней строкой
|
||||
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`; комментарии и docstring — по-русски.
|
||||
- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
|
||||
- Решения исполнителя вне плана — в таблицу `openspec/changes/agent-v15-sprints/design.md` (следующий свободный `R-n`);
|
||||
находки ревью задачи n — `T<n>.<k>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. `PUT /rest/agile/1.0/issue/rank` ответил 207 Multi-Status с ошибкой по задаче (Jira Agile так сообщает о частичном
|
||||
отказе) — `failed` с текстом Jira, а не `{changed: true}` (тест `test_rank_207_error` в Task 7).
|
||||
2. Спринт доски с состоянием вне `active`/`future`/`closed` или без `id`/`name` — хаб отверг бы всю пачку (`bad_state`);
|
||||
агент отбрасывает такой спринт с записью в лог, остальное уходит (тест `test_board_bad_sprint_dropped` в Task 3).
|
||||
3. Повтор `ensure_sprint_meta {sprint: "S", rename_to: "T"}` после успешного переименования — спринта «S» уже нет, есть «T»:
|
||||
`done` без записи, а не второй спринт и не «имя занято» (тест `test_meta_repeat_after_rename` в Task 5).
|
||||
4. `ensure_sprint_state closed` с `move_open_to` — неизвестное имя, закрытый спринт или тот же спринт — `failed` до
|
||||
первой записи, Jira не изменена (тест `test_state_bad_move_target_no_writes` в Task 6).
|
||||
5. Дата не в формате `ГГГГ-ММ-ДД` (`"28.10.2026"`, `"2026-13-01"`) — `failed` без вызова записи, а не 400 Jira или
|
||||
спринт с неверной датой (тест `test_meta_bad_date` в Task 5).
|
||||
|
||||
## Rulings при планировании
|
||||
|
||||
Маркеры продолжают нумерацию design агента (R-60…R-63). Task 8 переносит их в таблицу `design.md`.
|
||||
|
||||
| Маркер | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| R-64 | Поле ранга/спринта/оценки не найдено — ключ `rank`/`sprint_ids`/`story_points` у задач не передаётся (design агента 2–3), а не `null` (спека `agent`: «значение null у всех задач») | Сбой `GET /field` с `null` стёр бы значения в зеркале; хаб без поля сохраняет прежнее, у новой базы прежнее — `NULL` | Поле удалили в Jira — в зеркале остаются старые значения до ручной правки |
|
||||
| R-65 | Ошибка чтения спринтов доски — доска не входит в `boards` (хаб сохраняет прежние данные), причина в лог; ошибка чтения настроек при прочитанных спринтах — доска с `columns: null` | Доска из `boards` заменяет все свои спринты: `sprints: []` стёр бы историю; спека «прежние правила v12 … refresh продолжается» внутренне противоречива | Данные доски устаревают до следующего удачного `refresh` |
|
||||
| R-66 | Пустая пачка с `boards` — только когда в параметрах есть `boards` (хаб v15) и выборка пуста (`jql` без задач, `keys: []`); все ключи 400 — по-прежнему `failed` (v14, `test_all_keys_400_fails`); `pages` считает и пустую пачку | Без `boards` пустая пачка бесполезна; «ни один ключ не найден» — признак ошибки конфигурации (R-7 v4) | Хвост `refresh` по ключам удалённых задач — `failed`, данные досок в этот раз не обновятся |
|
||||
| R-67 | Любой `HttpError` на `GET /field` — поля не найдены до конца команды, следующая команда спрашивает снова; `ConnectionError` — как везде (повтор `refresh`); список полей один на команду и общий с журналом изменений (`field_id_of`) | design агента 1; один `GET /field` за `refresh` сохраняет `test_custom_field_lookup_once` | Нет |
|
||||
| R-68 | Смещение дат спринта — `datetime.now().astimezone().strftime("%z")`, не `time.strftime("%z")` (design агента 5) | `time.strftime("%z")` на Windows отдаёт имя пояса, а не `+0300` | Нет |
|
||||
| R-69 | `ensure_sprint_meta`: спринта `sprint` нет, а ровно один не закрытый спринт с именем `rename_to` есть — это он (повтор после переименования), правятся его поля; есть оба — «имя занято»; «несколько с именем» проверяется и для `rename_to` | Повтор команды не должен падать или создавать второй спринт (Anti-Goal proposal) | Нельзя создать новый спринт с именем существующего через `rename_to` — и не нужно |
|
||||
| R-70 | У `ensure_sprint_meta` и `ensure_sprint_state` `board_id` только из команды, без запасного `board_id` настроек; нет — `failed` «board_id нет в команде — спринты недоступны» | Новые типы ставит только хаб v15, он всегда дописывает доску; чужая доска из настроек опаснее отказа | Нет |
|
||||
| R-71 | Правило «несколько спринтов с именем» — и для `ensure_sprint_state`; `move_open_to` — активный или будущий спринт той же доски, не сам закрываемый, ровно один с этим именем; все проверки — до первой записи; спринт уже в нужном состоянии — `done` без проверки `move_open_to` | Повтор безопасен; ошибка параметров не оставляет спринт полузакрытым | Повтор с опечаткой в `move_open_to` после закрытия — `done`, опечатка не замечена |
|
||||
| R-72 | Спринт доски без `id`/`name` или с состоянием вне `active`/`future`/`closed` (после `lower()`) — отбрасывается с записью в лог | Хаб отвергает всю пачку (`bad_state`), `refresh` упал бы целиком | Такой спринт не виден в вебе |
|
||||
| R-73 | Ответ `PUT /issue/rank` с `entries[].status` не 2xx — `failed` «Jira не переранжировала <ключ>: <errors>» | Jira Agile сообщает о частичном отказе кодом 207 | Нет |
|
||||
| R-74 | Порядок кандидатов полей `(len(id), id)` — числовой порядок `customfield_N`, для всех трёх полей | design агента 1 («первое по id») задан только для оценки, строковое сравнение ставит `customfield_9999` после `customfield_10050` | Не то поле — перекрытие `field_ids` |
|
||||
| R-75 | Кэш полей и имён статусов — на экземпляр `Executor` (Runner пересоздаёт его при смене настроек) | «Один раз на процесс» с учётом смены `field_ids` | Нет |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Поиск полей Jira с кэшем и перекрытием
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — константы `RANK_SCHEMA`, `SPRINT_SCHEMA` у раздела исполнителя; `Executor.__init__` и `Executor.run`
|
||||
(кэши); `Executor.field_list`, `Executor.jira_fields`; `refresh_history.field_id_of` берёт `self.field_list()`
|
||||
- Modify: `tests/fake_jira.py` — константы `SPRINT_FIELD = "customfield_10104"`, `RANK_FIELD = "customfield_10105"`,
|
||||
`POINTS_FIELD = "customfield_10106"`; `self.fields`: у «Sprint» `schema: {"type": "array", "items": "string",
|
||||
"custom": "com.pyxis.greenhopper.jira:gh-sprint"}`, добавить `{"id": RANK_FIELD, "name": "Rank", "custom": True,
|
||||
"schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-lexo-rank"}}` и `{"id": POINTS_FIELD,
|
||||
"name": "Story Points", "custom": True, "schema": {"type": "number",
|
||||
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:float"}}`
|
||||
- Test: `tests/test_refresh.py` — новый класс `JiraFieldsTest` (setUp как у `RefreshTest`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `Executor.field_list(self) -> List[dict]` — `GET /rest/api/2/field` один раз на команду (`self._field_list`,
|
||||
сбрасывается в `run`), только элементы-словари; `HttpError`/`ConnectionError` наружу.
|
||||
- Produces: `Executor.jira_fields(self) -> Dict[str, Optional[str]]` — ключи `"rank"`, `"sprint"`, `"story_points"` → id
|
||||
поля или `None`. Все три в `field_ids` — без `GET /field`. Успех — кэш `self._jira_fields` с `self._jira_fields_ok = True`
|
||||
(живёт, пока жив `Executor`); `HttpError` — `log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % code)`,
|
||||
ненайденные — `None`, `_jira_fields_ok = False`: кэш живёт до конца команды, `run` его сбрасывает (R-67).
|
||||
|
||||
- [ ] **Step 1: Тесты** в `JiraFieldsTest`:
|
||||
|
||||
```python
|
||||
def test_fields_found_by_schema_and_name(self):
|
||||
want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"}
|
||||
self.assertEqual(self.ex.jira_fields(), want)
|
||||
self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив
|
||||
self.assertEqual(self.ex.jira_fields(), want)
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
|
||||
|
||||
def test_fields_override_skips_lookup(self):
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p")
|
||||
self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"})
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
|
||||
def test_fields_partial_override(self):
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000")
|
||||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||||
|
||||
def test_story_points_first_by_numeric_id_any_case(self):
|
||||
self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True},
|
||||
{"id": "customfield_10050", "name": "Story Points", "custom": True}]
|
||||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999")
|
||||
|
||||
def test_field_lookup_error_retried_next_command(self):
|
||||
self.fake.fail_next.append((403, {}))
|
||||
out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest
|
||||
self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None})
|
||||
self.assertTrue(any("HTTP 403" in l for l in logs), logs)
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем
|
||||
self.ex.run({"id": 2, "type": "nope", "params": {}})
|
||||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||||
```
|
||||
|
||||
`test_custom_field_lookup_once` (`tests/test_history.py`) не меняется и должен остаться зелёным.
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`AttributeError: jira_fields`).
|
||||
- [ ] **Step 3: Реализация** по сигнатурам из Interfaces; `logged` вынести из `RefreshTest` в общий миксин или
|
||||
повторить в `JiraFieldsTest` (на выбор исполнителя).
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/agent.py tests/fake_jira.py tests/test_refresh.py
|
||||
git commit -m "Агент: поиск полей ранга, спринта и оценки с кэшем и перекрытием field_ids (#49)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
### Task 2: `rank`, `story_points`, `sprint_ids` в состоянии `refresh`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.state_fields` (плюс найденные id из `jira_fields()`, без повторов),
|
||||
`Executor.to_state` (три поля), `Executor.sprint_ids_of`, `Executor.do_refresh` (лог ненайденных полей раз за `refresh`)
|
||||
- Modify: `tests/fake_jira.py` — `CUSTOM` дополнить `RANK_FIELD`, `POINTS_FIELD`; в `add` база `"sprint_ids": None`;
|
||||
`issue_json` отдаёт `SPRINT_FIELD`: если у задачи есть ключ `"sprint_raw"` — его как есть; иначе id из
|
||||
`sprint_ids` (если не `None`), иначе `[id спринта с именем sprint]` из `all_sprints()` (нет имени — `None`); элемент —
|
||||
`{"id", "name", "state"}` при `self.sprint_field_format == "object"` (по умолчанию) или строка
|
||||
`"com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s,sequence=%d]"` при `"string"`
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `jira_fields()` (Task 1).
|
||||
- Produces: `Executor.sprint_ids_of(self, key: str, value: Any) -> List[int]` — `None`/не список → `[]`; словарь — `id`
|
||||
(`int` или строка из цифр); строка — `re.search(r"[\[,]id=(\d+)", s)`; иное — пропуск с
|
||||
`log(" ! %s: элемент поля спринта не разобран" % key)`; повторы убираются.
|
||||
- Produces: `to_state` кладёт `rank` / `story_points` / `sprint_ids` только для найденных полей (R-64).
|
||||
|
||||
- [ ] **Step 1: Тесты** в `RefreshTest`:
|
||||
|
||||
```python
|
||||
def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач
|
||||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s}
|
||||
for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))]
|
||||
self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0)
|
||||
self.fake.add("AURORA-2")
|
||||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]),
|
||||
("0|i0001:", 3.0, [2, 3]))
|
||||
self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, []))
|
||||
fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0]
|
||||
for f in ("customfield_10104", "customfield_10105", "customfield_10106"):
|
||||
self.assertIn(f, fields)
|
||||
|
||||
def test_sprint_field_server_strings(self):
|
||||
self.fake.add("AURORA-1", sprint_raw=[
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]",
|
||||
"мусор", {"id": "74"}, {"name": "без id"}])
|
||||
out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"]))
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74])
|
||||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs)
|
||||
|
||||
def test_story_points_non_number_is_null(self):
|
||||
for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)):
|
||||
self.fake.add(key, customfield_10106=v)
|
||||
self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"])
|
||||
self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5])
|
||||
|
||||
def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh
|
||||
self.fake.fields = []
|
||||
for i in range(1, 121):
|
||||
self.fake.add("AURORA-%d" % i, customfield_10106=2)
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL))
|
||||
self.assertEqual(out["result"]["pages"], 2)
|
||||
for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]:
|
||||
self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st)
|
||||
for name in ("rank", "sprint_ids", "story_points"):
|
||||
self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs)
|
||||
```
|
||||
|
||||
Лог ненайденного поля — `log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (<ранга|спринта|оценки>, <rank|sprint_ids|story_points>))`,
|
||||
один раз за `refresh`, только если выборка не пуста.
|
||||
|
||||
Существующий `test_jira_error_has_no_peer`: первым вызовом Jira теперь стал `GET /field` (его 500 гасится, R-67) —
|
||||
перед `fail_next.append((500, {}))` добавить `self.ex.jira_fields()`, чтобы 500 получил `search`; проверки теста те же.
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`KeyError: 'rank'`).
|
||||
- [ ] **Step 3: Реализация** по Interfaces; `story_points` — `v if isinstance(v, (int, float)) and not isinstance(v, bool) else None`;
|
||||
`rank` — `v if isinstance(v, str) else None`.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: ранг, оценка и все спринты задачи в состоянии refresh (#49)` (+ Co-Authored-By, как в Task 1).
|
||||
|
||||
### Task 3: Данные досок в первой пачке `refresh`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.refresh_boards` (новая тройка), `Executor.status_names`, `Executor.board_columns`,
|
||||
`Executor.kanban_backlog`, `Executor.all_sprints`, `Executor.sprint_state` (статический), `Executor.board_state`,
|
||||
`Executor.do_refresh` (чтение досок до выборки, `boards` в первой пачке, пустая пачка)
|
||||
- Modify: `tests/fake_jira.py` — `STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ",
|
||||
"10003": "Разработка", "10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"}`;
|
||||
`COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])]`;
|
||||
`self.board_columns = {}` (доска → список как `COLUMNS`), `self.backlog_codes = {}` (доска → код ответа, по умолчанию 200),
|
||||
`self.fail_paths = {}` (путь → список кодов; каждый запрос по пути снимает первый и отвечает им, проверяется в `handle`
|
||||
сразу после `fail_next`); маршруты `GET /rest/api/2/status` (`[{"id", "name"}]` из `STATUSES`),
|
||||
`GET /rest/agile/1.0/board/{id}/configuration` (`{"id", "columnConfig": {"columns": [{"name", "statuses": [{"id"}]}]}}`,
|
||||
нет доски — 404), `GET /rest/agile/1.0/board/{id}/backlog` (код из `backlog_codes`; 200 — `{"startAt": 0, "maxResults": 0, "total": 0, "issues": []}`);
|
||||
`GET /board/{id}/sprint` без параметра `state` — спринты всех состояний (с `state` — как сейчас)
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `refresh_boards(self, p) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]` —
|
||||
`(scrum, typed, types)`: `typed` — доски из `p["boards"]` с типом `scrum`/`kanban` (известным или определённым), без
|
||||
повторов, в порядке параметров; без `boards` в `p` — `([board_id настроек] или [], [], None)`.
|
||||
- Produces: `status_names(self) -> Dict[str, str]` — id → имя, кэш на экземпляр; `HttpError` наружу, не кэшируется.
|
||||
- Produces: `board_columns(self, board) -> Optional[List[dict]]` — `[{"name", "statuses": [имя]}]` по
|
||||
`columnConfig.columns`; неизвестный id статуса пропускается; `HttpError` (настройки или статусы) —
|
||||
`log("доска %s — колонки не прочитаны (HTTP %d)" % (board, code))`, `None`. Используется и в Task 6.
|
||||
- Produces: `kanban_backlog(self, board) -> Optional[bool]` — `GET /board/{id}/backlog?maxResults=0`: ответ → `True`,
|
||||
`HttpError` 400 → `False`, иной `HttpError` → `None` с записью в лог.
|
||||
- Produces: `all_sprints(self, board) -> List[dict]` — `GET /board/{id}/sprint` без `state`, постранично по 50
|
||||
(`isLast`), кэш на команду `self._all_sprints[str(board)]` (сброс в `run`); при успехе заполняет
|
||||
`self._sprints[str(board)]` именами активных и будущих (тот же порядок), чтобы `sprints()` не спрашивал Jira повторно;
|
||||
`HttpError` наружу. Используется в Task 5 и 6.
|
||||
- Produces: `sprint_state(sp: dict) -> Optional[dict]` — `{"id", "name", "state": state.lower(), "start_date": startDate,
|
||||
"end_date": endDate, "complete_date": completeDate, "goal"}`; без `id`/`name` или состояние вне трёх — `None` (R-72).
|
||||
- Produces: `board_state(self, board, kind: str) -> Optional[dict]` — элемент `boards` (Global Constraints): колонки —
|
||||
`board_columns`; `kanban_backlog` — только у `kanban`, у `scrum` `None`; спринты — у `scrum` `all_sprints`, у `kanban`
|
||||
`[]`; `HttpError` спринтов — `log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" ...)`,
|
||||
`None` (R-65); отброшенный спринт — `log("refresh: доска %s — спринт %r пропущен: …" ...)`.
|
||||
- `do_refresh`: порядок — `refresh_boards` → `boards = [board_state(...)…, без None] if "boards" in p else None` → выборка.
|
||||
Первая пачка несёт `"boards": boards`, если `boards is not None`; выборка пуста (`keys` пусты или `jql` без задач) и
|
||||
`boards is not None` — одна пустая пачка, `pages = 1` (R-66); без `boards` в `p` — как v14.
|
||||
|
||||
- [ ] **Step 1: Тесты** (`COLS` — модульная константа теста: ожидаемые колонки из `COLUMNS` фейка, имена статусов):
|
||||
|
||||
```python
|
||||
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
|
||||
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
|
||||
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
|
||||
|
||||
def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами»
|
||||
sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw)
|
||||
self.fake.board_sprints[4216] = [
|
||||
sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00",
|
||||
completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"),
|
||||
sp(2, "closed"), sp(3, "active"), sp(4, "future")]
|
||||
for i in range(1, 121):
|
||||
self.fake.add("AURORA-%d" % i, sprint="S3")
|
||||
self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3]
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
b = self.hub.states[0]["boards"]
|
||||
self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)])
|
||||
self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4])
|
||||
self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed",
|
||||
"start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00",
|
||||
"complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"})
|
||||
self.assertNotIn("boards", self.hub.states[1])
|
||||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3"))
|
||||
sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
|
||||
self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка
|
||||
self.assertNotIn("state", sprint_reads[0])
|
||||
|
||||
def test_all_sprints_paged(self): ... # 120 спринтов доски → 3 запроса, в boards все 120
|
||||
|
||||
def test_kanban_backlog_flag(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
for code, want in ((200, True), (400, False), (500, None)):
|
||||
self.fake.backlog_codes[5120] = code
|
||||
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
|
||||
b = self.hub.states[-1]["boards"][0]
|
||||
self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS))
|
||||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests])
|
||||
|
||||
def test_board_config_error_columns_null(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
b = self.hub.states[0]["boards"][0]
|
||||
self.assertIsNone(b["columns"])
|
||||
self.assertEqual([s["id"] for s in b["sprints"]], [1, 2])
|
||||
self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs)
|
||||
|
||||
def test_board_sprints_error_board_omitted(self): # R-65
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.hub.states[0]["boards"], [])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_board_bad_sprint_dropped(self): # Review Focus 2
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"},
|
||||
{"id": 3, "state": "active"}]
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
|
||||
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
|
||||
|
||||
def test_unknown_status_id_skipped_and_status_cached(self):
|
||||
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])]
|
||||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1)
|
||||
|
||||
def test_empty_jql_with_boards_sends_empty_batch(self):
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(out["result"]["pages"], 1)
|
||||
st = self.hub.states[0]
|
||||
self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True))
|
||||
self.assertEqual(st["boards"][0]["id"], 4216)
|
||||
```
|
||||
|
||||
Существующие тесты поменять так: `test_empty_keys_still_resolves_boards` — `pages` 0 → 1, плюс
|
||||
`self.hub.states == [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [], "neighbor_keys": [],
|
||||
"page": 1, "last_page": True, "boards": [{"id": 5120, "type": "kanban", "columns": COLS, "kanban_backlog": True, "sprints": []}]}]`;
|
||||
`test_old_hub_without_boards` — добавить `assertNotIn("boards", self.hub.states[0])` и отсутствие запросов
|
||||
`/configuration`; `test_refresh_empty_keys` и `test_refresh_empty_jql_result` (без `boards`) — без изменений.
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация** по Interfaces.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS (в том числе прежние тесты досок: самоисправление канбана R-36 и
|
||||
«доска 9999» R-32 идут через `sprints()` / `board_sprints`, как раньше).
|
||||
- [ ] **Step 5: Commit** — `Агент: колонки, kanban-бэклог и все спринты досок в первой пачке refresh (#49)` (+ Co-Authored-By).
|
||||
|
||||
### Task 4: `story_points` в `ensure_fields`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.do_ensure_fields`
|
||||
- Test: `tests/test_executor_fields.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `jira_fields()["story_points"]` (Task 1).
|
||||
- Поведение: `"story_points" in p` — до `GET /issue`: поле не найдено — `ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points")`;
|
||||
значение не `None` и не число ≥ 0 (`bool` — не число) — `failed` «story_points — число ≥ 0 или null» без вызова Jira
|
||||
после `GET /field`. Сравнение — Global Constraints; отличие — поле в `PUT`, `"story_points"` в `changed`.
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
|
||||
```python
|
||||
def test_story_points_set_clear_idempotent(self):
|
||||
self.fake.add("AURORA-1")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
|
||||
self.assertEqual(out["result"], {"changed": ["story_points"]})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3)
|
||||
for v in (3, 3.0):
|
||||
again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
|
||||
self.assertEqual(again["result"], {"changed": []})
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", story_points=None)
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_10106"])
|
||||
|
||||
def test_story_points_no_field(self): # сценарий «Нет поля оценки»
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fields = []
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "поле оценки не найдено в Jira: задайте field_ids.story_points"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_story_points_override(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", story_points=5)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_20000"], 5)
|
||||
self.assertNotIn(("GET", "/rest/api/2/field"), self.fake.requests)
|
||||
|
||||
def test_story_points_bad_value(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for v in ("3", -1, True):
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"), v)
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_fields -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация.**
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: оценка story_points в ensure_fields (#49)` (+ Co-Authored-By).
|
||||
|
||||
### Task 5: Команда `ensure_sprint_meta`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — модульные `_is_day(value: Any) -> bool`, `_sprint_time(day: str, hour: int) -> str`;
|
||||
`Executor.sprints_named`, `Executor.sprint_board`, `Executor.do_ensure_sprint_meta`
|
||||
- Modify: `tests/fake_jira.py` — `self.sprint_seq = 500`, `self.sprint_posts = []` (`(путь, тело)` каждой записи спринта);
|
||||
`POST /rest/agile/1.0/sprint` — новый спринт `{"id": sprint_seq, "name", "state": "future", "originBoardId", "startDate"?,
|
||||
"endDate"?, "goal"?}` в `board_sprints[originBoardId]`, если доска там есть, иначе в `self.sprints`; ответ 201 со спринтом;
|
||||
`POST /rest/agile/1.0/sprint/{id}` — частичная правка (`name`, `startDate`, `endDate`, `goal`, `state`); `state:
|
||||
"active"` без обеих дат после правки или не из `future`, `state: "closed"` не из `active` — 400; ответ 200 со спринтом
|
||||
- Modify: `tests/_common.py` — `ZERO_PACING` дополнить `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `fetch_attachment` (0)
|
||||
- Create: `tests/test_executor_sprints.py` — класс `ExecutorSprintsTest` (setUp как в `test_executor_links.py`: `FakeJira`,
|
||||
`Executor(..., dict(SETTINGS))`, `self.gaps` — `ex.sleep`; `run_cmd(ctype, **params)`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `all_sprints(board)` (Task 3).
|
||||
- Produces: `_is_day(value) -> bool` — строка `ГГГГ-ММ-ДД` (`re.fullmatch(r"\d{4}-\d{2}-\d{2}")` и `datetime.strptime(value, "%Y-%m-%d")`).
|
||||
- Produces: `_sprint_time(day, hour) -> str` — `"%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z"))` (R-68).
|
||||
- Produces: `Executor.sprint_board(self, p: dict) -> Any` — `p.get("board_id")`; нет — `ConfigGap("board_id нет в команде — спринты недоступны")` (R-70).
|
||||
- Produces: `Executor.sprints_named(self, board, name: str) -> List[dict]` — спринты доски из `all_sprints` с точным именем.
|
||||
- Produces: `Executor.many_sprints(name: str, found: List[dict]) -> str` (статический) — «несколько спринтов с именем «<имя>»: 5, 6».
|
||||
- `do_ensure_sprint_meta(p, cmd)`: даты проверяются первыми (`failed` «<поле>: дата не в формате ГГГГ-ММ-ДД: <repr>»);
|
||||
поиск по `sprint`; правила R-69 для `rename_to`; закрытый — «спринт закрыт»; создание — `POST /sprint` `{"name": rename_to or sprint,
|
||||
"originBoardId": int(board), "startDate"?, "endDate"?, "goal"?}` → `{"sprint_id", "created": True}`; найден — `POST /sprint/{id}` только
|
||||
с отличающимися `name`/`startDate`/`endDate`/`goal` (`goal` `None` ≡ `""`), нет отличий — без записи → `{"sprint_id", "created": False}`.
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
|
||||
```python
|
||||
B = {"board_id": 4216}
|
||||
|
||||
def test_meta_create_then_repeat(self): # сценарий «Создание спринта повтором не дублируется»
|
||||
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}]
|
||||
params = dict(B, sprint="AWPCA_Sprint_4", start_date="2026-10-28", end_date="2026-11-11", goal="свод ВГО")
|
||||
out = self.run_cmd("ensure_sprint_meta", **params)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
sid = out["result"]["sprint_id"]
|
||||
self.assertEqual(out["result"], {"sprint_id": sid, "created": True})
|
||||
sp = self.fake.board_sprints[4216][-1]
|
||||
off = datetime.now().astimezone().strftime("%z")
|
||||
self.assertEqual((sp["id"], sp["name"], sp["state"], sp["originBoardId"], sp["goal"]), (sid, "AWPCA_Sprint_4", "future", 4216, "свод ВГО"))
|
||||
self.assertEqual((sp["startDate"], sp["endDate"]), ("2026-10-28T09:00:00.000" + off, "2026-11-11T18:00:00.000" + off))
|
||||
again = self.run_cmd("ensure_sprint_meta", **params)
|
||||
self.assertEqual(again["result"], {"sprint_id": sid, "created": False})
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
|
||||
def test_meta_updates_only_diff(self): # даты совпадают по дню, отличается цель
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future", "goal": "старая",
|
||||
"startDate": "2026-10-28T10:00:00.000+03:00", "endDate": "2026-11-11T19:00:00.000+03:00"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", start_date="2026-10-28", end_date="2026-11-11", goal="новая", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"goal": "новая"})])
|
||||
|
||||
def test_meta_rename_and_name_taken(self): ... # S → T: POST {"name": "T"}; есть S и T — «имя занято», без записи
|
||||
|
||||
def test_meta_repeat_after_rename(self): # Review Focus 3, R-69
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "T", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_closed_and_duplicates(self):
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "C", "state": "closed"},
|
||||
{"id": 5, "name": "D", "state": "future"}, {"id": 6, "name": "D", "state": "active"}]
|
||||
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="C", goal="x", **B)["error"], "спринт закрыт")
|
||||
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="D", **B)["error"], "несколько спринтов с именем «D»: 5, 6")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_bad_date(self): # Review Focus 5
|
||||
for bad in ("28.10.2026", "2026-13-01"):
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="N", start_date=bad, **B)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("ГГГГ-ММ-ДД", out["error"])
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_without_board(self):
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="N")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны"))
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL («неизвестный тип команды»).
|
||||
- [ ] **Step 3: Реализация** по Interfaces.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_meta — создание и правка спринта (#49)` (+ Co-Authored-By).
|
||||
|
||||
### Task 6: Команда `ensure_sprint_state`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.sprint_issues`, `Executor.do_ensure_sprint_state`
|
||||
- Modify: `tests/fake_jira.py` — `GET /sprint/{id}/issue` без `jql` — все задачи спринта (по `sprint_members` или имени
|
||||
спринта задачи); каждый элемент `{"key", "fields": {"status": {"name"}}}`; `POST /sprint/{id}/issue` дописывает id в
|
||||
`sprint_ids` задачи, если тот не `None`
|
||||
- Test: `tests/test_executor_sprints.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `sprint_board`, `sprints_named`, `many_sprints` (Task 5); `all_sprints`, `board_columns` (Task 3); `self.gap("ensure_sprint_state")`.
|
||||
- Produces: `Executor.sprint_issues(self, sprint_id) -> List[Tuple[str, Optional[str]]]` — `(ключ, статус)` из
|
||||
`GET /sprint/{id}/issue?fields=status`, постранично по 50 (`total`).
|
||||
- `do_ensure_sprint_state(p, cmd)` — порядок проверок: `state` не `active`/`closed` — `failed` «state — active или closed»;
|
||||
доска; спринт (нет — `failed` «нет спринта «<имя>» на доске <N>», несколько — `many_sprints`); уже в нужном
|
||||
состоянии — `done {"sprint_id", "moved": []}` без записи; `active`: закрытый — «спринт закрыт», нет `startDate` или
|
||||
`endDate` — «у спринта нет дат: задайте ensure_sprint_meta», иначе `POST /sprint/{id}` `{"state": "active", "startDate", "endDate"}`
|
||||
(значения спринта); `closed`: будущий — «спринт не начат»; нет `move_open_to` — «move_open_to обязателен при state = closed»;
|
||||
цель (R-71) — `backlog` или ровно один активный/будущий спринт доски с этим именем, не сам спринт, иначе
|
||||
`failed` «нет спринта «<имя>» среди активных и будущих доски <N>»; колонки (`board_columns`, `None` или `[]` —
|
||||
«не удалось прочитать колонки доски»); незавершённые — статус не в последней колонке; переносы по 50
|
||||
(`POST /sprint/{to}/issue` или `POST /backlog/issue` `{"issues": [...]}`), затем `POST /sprint/{id}` `{"state": "closed"}`;
|
||||
перед каждой записью, кроме первой, — `self.gap("ensure_sprint_state")` → `{"sprint_id", "moved": [ключи в порядке Jira]}`.
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
|
||||
```python
|
||||
def sprint_with(self, n_done, n_open): # спринт 3 активный, спринт 4 будущий, задачи по имени спринта
|
||||
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"},
|
||||
{"id": 4, "name": "AWPCA_Sprint_4", "state": "future"}]
|
||||
open_keys = []
|
||||
for i in range(n_done + n_open):
|
||||
key = "AURORA-%d" % (2000 + i)
|
||||
self.fake.add(key, sprint="AWPCA_Sprint_3_Corrections", status="Готово" if i < n_done else "Разработка")
|
||||
if i >= n_done:
|
||||
open_keys.append(key)
|
||||
return open_keys
|
||||
|
||||
def test_close_with_move(self): # сценарий «Завершение спринта с переносом»
|
||||
open_keys = self.sprint_with(25, 12)
|
||||
params = dict(B, sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="AWPCA_Sprint_4")
|
||||
out = self.run_cmd("ensure_sprint_state", **params)
|
||||
self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys})
|
||||
self.assertEqual({self.fake.issues[k]["sprint"] for k in open_keys}, {"AWPCA_Sprint_4"})
|
||||
self.assertEqual(self.fake.board_sprints[4216][0]["state"], "closed")
|
||||
writes = len(self.fake.writes())
|
||||
again = self.run_cmd("ensure_sprint_state", **params)
|
||||
self.assertEqual(again["result"], {"sprint_id": 3, "moved": []})
|
||||
self.assertEqual(len(self.fake.writes()), writes)
|
||||
|
||||
def test_close_chunks_and_gap(self):
|
||||
self.sprint_with(0, 60)
|
||||
self.ex.s["pacing"] = {"ensure_sprint_state": 30}
|
||||
self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual([p for _, p in self.fake.writes()],
|
||||
["/rest/agile/1.0/backlog/issue"] * 2 + ["/rest/agile/1.0/sprint/3"])
|
||||
self.assertEqual(self.gaps, [30, 30])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/agile/1.0/sprint/3/issue")), 2)
|
||||
|
||||
def test_activate_future(self): ... # даты есть → POST {"state": "active", startDate, endDate}; повтор — без записи;
|
||||
# без дат → «у спринта нет дат: задайте ensure_sprint_meta», writes() == []
|
||||
def test_state_wrong_transitions(self): ... # closed для future → «спринт не начат»; active для closed → «спринт закрыт»; без записи
|
||||
|
||||
def test_state_bad_move_target_no_writes(self): # Review Focus 4
|
||||
self.sprint_with(1, 1)
|
||||
self.fake.board_sprints[4216].append({"id": 1, "name": "Старый", "state": "closed"})
|
||||
for target in ("Нет такого", "Старый", "AWPCA_Sprint_3_Corrections"):
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to=target, **B)
|
||||
self.assertEqual(out["status"], "failed", target)
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_close_columns_unreadable(self):
|
||||
self.sprint_with(1, 1)
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация** по Interfaces.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_state — запуск и завершение спринта с переносом (#49)` (+ Co-Authored-By).
|
||||
|
||||
### Task 7: Команда `ensure_rank`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `Executor.do_ensure_rank`
|
||||
- Modify: `tests/fake_jira.py` — `PUT /rest/agile/1.0/issue/rank`: тело в `self.rank_bodies`; задачи с ключом в
|
||||
`self.rank_errors` (ключ → текст) — ответ 207 `{"entries": [{"issueKey", "issueId", "status": 400, "errors": [текст]}]}`
|
||||
без изменения; иначе ранг `RANK_FIELD` задачи становится строго меньше (`rankBeforeIssue`) или больше
|
||||
(`rankAfterIssue`) ранга другой задачи (например, `before[:-1] + chr(ord(before[-1]) - 1) + "z"` и `after + "m"`), ответ 204
|
||||
- Test: `tests/test_executor_sprints.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `jira_fields()["rank"]` (Task 1).
|
||||
- `do_ensure_rank(p, cmd)`: ровно одно из `before`/`after` — иначе `failed` «нужно ровно одно из before, after»; другая
|
||||
задача — та же — `failed` «задача не может стоять выше или ниже самой себя»; поле ранга не найдено —
|
||||
`failed` «поле ранга не найдено»; ранги — один `GET /search` `jql="key in (<key>,<other>)"`, `fields=<id ранга>`;
|
||||
`before` и `rank(key) < rank(other)` или `after` и `>` (оба — строки) — `done {"changed": False}`; иначе
|
||||
`PUT /rest/agile/1.0/issue/rank` `{"issues": [key], "rankBeforeIssue" | "rankAfterIssue": other, "rankCustomFieldId": <число из id>}`
|
||||
(id без цифрового хвоста — без `rankCustomFieldId`); `entries` с `status` не 2xx — `failed`
|
||||
«Jira не переранжировала <ключ>: <errors через «; »>» (R-73); иначе `done {"changed": True}`.
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
|
||||
```python
|
||||
def test_rank_already(self): # сценарий «Ранг уже такой»
|
||||
self.fake.add("AURORA-1612", customfield_10105="0|i0001:")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
|
||||
self.assertEqual((out["status"], out["result"]), ("done", {"changed": False}))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_rank_before_and_after_write(self):
|
||||
self.fake.add("AURORA-1612", customfield_10105="0|i0003:")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
|
||||
self.assertEqual(out["result"], {"changed": True})
|
||||
self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105})
|
||||
self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")["result"], {"changed": False})
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", after="AURORA-1601")["result"], {"changed": True})
|
||||
self.assertGreater(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
|
||||
|
||||
def test_rank_null_rank_writes(self): ... # у key ранга нет (None) → PUT, changed True
|
||||
def test_rank_bad_params(self): ... # before и after вместе, ни одного, key == before → failed, без записи
|
||||
|
||||
def test_rank_no_field(self):
|
||||
self.fake.add("AURORA-1"); self.fake.add("AURORA-2")
|
||||
self.fake.fields = []
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1", after="AURORA-2")["error"], "поле ранга не найдено")
|
||||
|
||||
def test_rank_207_error(self): # Review Focus 1
|
||||
self.fake.add("AURORA-1", customfield_10105="0|i0009:"); self.fake.add("AURORA-2", customfield_10105="0|i0001:")
|
||||
self.fake.rank_errors["AURORA-1"] = "Ранжирование недоступно"
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1", before="AURORA-2")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "Jira не переранжировала AURORA-1: Ранжирование недоступно"))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL.
|
||||
- [ ] **Step 3: Реализация** по Interfaces.
|
||||
- [ ] **Step 4: Прогон** — весь набор → PASS.
|
||||
- [ ] **Step 5: Commit** — `Агент: команда ensure_rank — выше или ниже другой задачи (#49)` (+ Co-Authored-By).
|
||||
|
||||
### Task 8: Версия 15 и документация
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/agent.py` — `AGENT_VERSION = 15`
|
||||
- Modify: `README.md` — таблица «Что делает агент с командами»: строки `ensure_sprint_meta`, `ensure_sprint_state`,
|
||||
`ensure_rank`; в `ensure_fields` — `story_points`; в `refresh` — `rank`, `story_points`, `sprint_ids`, данные досок
|
||||
(`boards` в первой пачке, пустая пачка); абзац «Версия»: «сейчас 15» и «Версия 15 — …» (поиск полей по `GET /field`,
|
||||
перекрытие `field_ids.rank`/`sprint`/`story_points`, проверка на Jira банка при выкатке; агент v14 на новые типы отвечает
|
||||
«неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки)
|
||||
- Modify: `ARCHITECTURE.md` — строка «Исполнитель»: `jira_fields`/`field_list`, `board_state`, `all_sprints`, новые `do_<тип>`;
|
||||
«Открытые вопросы»: снять «Спринт задач закрытых спринтов: `sprint = null`» (история — `sprint_ids`), оставить про `sprint`
|
||||
- Modify: `CLAUDE.md` — «Последний change» → `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг,
|
||||
оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — этот файл; в «Тесты» —
|
||||
`test_executor_sprints.py` (`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`)
|
||||
- Modify: `openspec/changes/agent-v15-sprints/design.md` — в таблицу «Decisions made without discussion» строки R-64…R-75 из
|
||||
раздела «Rulings при планировании» (дата 2026-10-24) и решения исполнителя, если были
|
||||
- Test: `tests/test_refresh.py`
|
||||
|
||||
- [ ] **Step 1: Тест** — `test_agent_version_15`: `self.assertEqual(agent.AGENT_VERSION, 15)`; после `refresh` с одной задачей
|
||||
`self.hub.states[0]["agent_version"] == 15`.
|
||||
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (14 != 15).
|
||||
- [ ] **Step 3: Реализация** — версия и документы по списку Files.
|
||||
- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS; `/usr/bin/python3 -m py_compile src/agent.py` без вывода.
|
||||
- [ ] **Step 5: Commit** — `Агент v15: версия и документация спринтов, ранга и оценок (#49)` (+ Co-Authored-By).
|
||||
48
docs/superpowers/plans/2026-10-25-sp-estimation-agent.md
Normal file
48
docs/superpowers/plans/2026-10-25-sp-estimation-agent.md
Normal file
@ -0,0 +1,48 @@
|
||||
# Агент v16: оценка через доску Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `ensure_fields` со `story_points` и `board_id` ставит оценку через `/rest/agile/1.0/issue/{key}/estimation?boardId=…`.
|
||||
|
||||
**Architecture:** в `Executor.do_ensure_fields` ветка оценки с доской отделяется от `PUT /issue`: сначала остальные
|
||||
поля (как v15, `GET /issue` только если они есть), затем `GET`/`PUT` оценки доски. Без `board_id` — путь v15.
|
||||
|
||||
**Tech Stack:** Python 3.9 stdlib, unittest; фейк Jira — `tests/fake_jira.py`.
|
||||
|
||||
**Spec:** `sdlc-hub/openspec/changes/sp-estimation-board/specs/agent/spec.md`, `…/specs/commands/spec.md`; решения — `openspec/changes/agent-v16-sp-estimation/design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `AGENT_VERSION = 16`; Python 3.9, только stdlib (`tests/test_py39.py`).
|
||||
- Тело `PUT`: `{"value": <число или null>}`; путь `AGILE + "/issue/%s/estimation"` с параметром `boardId`.
|
||||
- Сравнение — `_same_points`; плохое значение — `failed` «story_points — число ≥ 0 или null» до вызовов Jira.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Команда только с оценкой — без `GET /issue` и `PUT /issue`; `jira_calls` 2 (запись) и 1 (без изменений).
|
||||
2. Оценка и другие поля — `PUT /issue` без поля оценки, затем `PUT estimation`; `changed` — все поля.
|
||||
3. Ошибка Jira на `PUT estimation` (400) — `failed`, остальные поля уже записаны.
|
||||
4. `null` при пустой оценке — без записи; при `1.0` — `PUT {"value": null}`.
|
||||
5. Без `board_id` — поведение v15 (существующие тесты `test_story_points_*` не меняются).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Оценка через estimation доски
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/fake_jira.py` (маршрут `/rest/agile/1.0/issue/{key}/estimation`: `GET` → `{"fieldId": POINTS_FIELD, "value": <поле задачи>}`, `PUT` → пишет `value` в поле задачи, тело в `self.estimation_bodies`; доска не из `self.boards` → 400; задачи нет → 404; маршрут — до общего `/rest/agile/1.0/issue/([^/]+)$`)
|
||||
- Modify: `src/agent.py` (`do_ensure_fields`, `AGENT_VERSION = 16`)
|
||||
- Test: `tests/test_executor_fields.py` (раздел `# --- story_points через доску (#53)`)
|
||||
|
||||
- [ ] **Step 1: Тесты:**
|
||||
- `test_points_by_board_set` — оценка `1.0`, `story_points=2, board_id=4216`: `result == {"changed": ["story_points"]}`, `jira_calls == 2`, `estimation_bodies == [{"value": 2}]`, `("PUT", "/rest/api/2/issue/AURORA-1704")` и `("GET", "/rest/api/2/issue/AURORA-1704")` нет в `requests`;
|
||||
- `test_points_by_board_same` — оценка `2.0`, `story_points=2`: `changed == []`, `jira_calls == 1`, записей нет;
|
||||
- `test_points_by_board_clear` — оценка `1.0`, `story_points=None` → тело `{"value": None}`; при пустой оценке — без записи;
|
||||
- `test_points_by_board_with_fields` — `duedate` + `story_points`: `PUT /issue` без `customfield_10106`, потом `PUT estimation`; `changed == ["duedate", "story_points"]`;
|
||||
- `test_points_by_board_jira_error` — доска 9999 (нет у фейка): `failed`, `duedate` уже записан;
|
||||
- `test_points_by_board_no_field_lookup` — `fake.fields = []`: всё равно `done` (без `GET /field`);
|
||||
- `test_points_by_board_bad_value` — `"3"` → `failed`, `requests` пуст.
|
||||
- [ ] **Step 2:** `/usr/bin/python3 -m unittest tests.test_executor_fields` — FAIL.
|
||||
- [ ] **Step 3:** `do_ensure_fields`: при `board_id` и `story_points` — путь доски (R-1…R-3 design), иначе v15; `AGENT_VERSION = 16`.
|
||||
- [ ] **Step 4:** `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный.
|
||||
- [ ] **Step 5:** commit «Агент v16: оценка через estimation доски (#53)».
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-15
|
||||
@ -0,0 +1,22 @@
|
||||
# agent-v10-comments — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/issue-card-modal/` (issue aep/jira-hub#37, aep/jira-agent#27).
|
||||
Агентская часть — решения 1–4 дизайна хаба (поле `comment` в `search`, дочитывание, форма комментария, версия и выкатка)
|
||||
и требование `agent` «Чтение состояния».
|
||||
|
||||
Решения владельца 15.10.2026 (brainstorming jira-hub#37): комментарии задачи нужны в карточке веб-приложения; два change
|
||||
по репозиториям, сначала агент; автор — отображаемое имя, как в Jira над комментарием.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Рулинги R-1…R-5 записаны в `jira-hub/openspec/changes/issue-card-modal/design.md`
|
||||
(раздел «Decisions made without discussion»); здесь — только решения, принятые при исполнении агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-20 | 2026-10-15 | Размер пачки `POST /agent/state` с комментариями не ограничивается отдельно: пачка по-прежнему 100 задач | Риск принят в design хаба (Risks): типичные комментарии — сотни килобайт на пачку при пределе хаба 16 МБ (финальное ревью, «Declined to judge») | Если пачка превысит 16 МБ, хаб ответит 413 и упадёт весь `refresh` — зеркало перестанет обновляться; лечится уменьшением пачки |
|
||||
| R-21 | 2026-10-15 | Ограничения видимости комментариев Jira (`visibility` по группе/роли) в хаб не переносятся: комментарий уходит как есть | Хаб личный, учётка агента видит только разрешённое ей; в спеке требования нет (финальное ревью, «Declined to judge») | Комментарий с ограничением видимости виден любому с доступом к веб-приложению хаба |
|
||||
|
||||
Нумерация рулингов агентской части — с R-20, чтобы не пересечься с рулингами исполнения хабовой части (R-6 и далее в design.md хаба).
|
||||
|
||||
Финальное ревью ветки (opus, 15.10.2026), маркеры с пометкой «агент»: I-1 — fixed 28b21e0 (возвращены проверки `test_refresh_empty_keys`); M-1…M-5 — deferred (follow-up при закрытии #37).
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
@ -0,0 +1,29 @@
|
||||
# agent-v11-bug-attachments — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/bug-and-attachments/` (issue aep/jira-hub#43, aep/jira-agent#31).
|
||||
Агентская часть — раздел «Агент v11» дизайна хаба и требования `agent` «Тип задачи и вложения» (ADDED) и
|
||||
«Чтение состояния» (MODIFIED: M-1, M-2 из aep/jira-agent#30).
|
||||
|
||||
Решения владельца 15.10.2026 (brainstorming jira-hub#43): файл приходит только из MCP; обязательные поля «Ошибки» — как у
|
||||
«Задачи»; файл хранится в хабе, агент забирает его отдельным запросом `GET /agent/files/{sha256}` (base64 в JSON).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения 1–8 записаны в `jira-hub/openspec/changes/bug-and-attachments/design.md`
|
||||
(раздел «Decisions made without discussion»); здесь — только решения агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-20 | 2026-10-15 | Любой 404 от `GET /agent/files/{sha}` — `failed` «файл удалён с хаба, загрузите заново», без разбора кода `no_file` в теле | Спека hub-api: у маршрута 404 бывает только `no_file`; агент v11 публикуется после выкатки хаба с маршрутом (design «Выкатка», шаги 1 и 3) | Если хаб без маршрута (откат хаба) — вводящий в заблуждение текст «загрузите заново» вместо «нет маршрута»; повторная загрузка не поможет, причина видна по логу хаба |
|
||||
| R-21 | 2026-10-15 | Таймаут `GET /agent/files` — обычный `timeout` агента (60 с), не поднимается до 120 с, как у загрузки в Jira | design Risks: круг папки для файла 10 МБ (≈13,4 МБ JSON) — секунды, «таймаут вызова агента 60 с с запасом»; таймаут — `ConnectionError` → повтор как обрыв связи с хабом | На медленной папке большой файл не успеет за 60 с: 4 попытки и `failed` «результат неизвестен»; лечится `timeout` в `agent.json` |
|
||||
| R-22 | 2026-10-15 | Ответ хаба на файл с кодом 2xx, но не JSON — временный сбой («ответ на файл <sha> не JSON») без текста `HttpError` | Текст `Http` содержит начало тела, то есть base64 содержимого, а спека запрещает содержимое в журнале и логе; обрезанный ответ — сбой пути, как повреждение | Диагностика беднее: начала тела в ошибке нет, причину искать по логу хаба/реле |
|
||||
| R-23 | 2026-10-15 | В `filename="…"` кавычка и переводы строк кодируются как у браузеров (`%22`, `%0D`, `%0A`); при поиске уже приложенного вложения сравниваются обе формы имени — исходная и «браузерная» | Запасная защита: с решения 20 хаба (16.10.2026) хаб отвергает `"` и управляющие символы при постановке, и для его команд путь не срабатывает; остаётся на случай старого хаба или ручной постановки. Сырая кавычка ломает заголовок части; если Jira сохранит имя в закодированной форме, без второй формы каждый повтор команды создавал бы дубль | Вложение с такими символами в Jira может называться `…%22…`; другое вложение, чьё имя совпало с «браузерной» формой, будет принято за уже приложенное |
|
||||
| R-24 | 2026-10-15 | Ответ Jira на загрузку не список или без `id` у первого элемента — `failed` «Jira не вернула id вложения в ответе на загрузку», без повтора | Спека: результат — `attachment_id` из ответа; повтор команды человеком найдёт вложение по имени и размеру и вернёт `done` с id | Если Jira на самом деле не сохранила вложение, `failed` без повтора требует ручного повтора команды |
|
||||
| R-25 | 2026-10-15 | Загрузка вложения идёт через тот же повтор 429/503 по `Retry-After`, что и остальные вызовы Jira (`JiraClient._retry`) | 429/503 — запрос не обработан, повтор безопасен; спека «Ограничение темпа и 429» не делает исключений для записи | 503 от прокси после фактической записи даст второе вложение; вероятность низкая, лишнее удаляется руками |
|
||||
| R-26 | 2026-10-15 | Тип задачи проверяется до поиска дубля в `ensure_issue`: без настройки типа — ни одного вызова Jira, даже если задача с той же темой уже есть | Спека: «`failed` без вызова Jira»; настройку могли убрать между постановкой и выдачей (design решение 6) | Повтор `ensure_issue` по уже созданной задаче после удаления настройки типа даёт `failed` вместо `done` с найденным ключом |
|
||||
| R-27 | 2026-10-16 | Трейлер коммитов исполнения — `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>`, а не `Opus 5.5` из текста плана | Указание окружения об атрибуции приоритетнее плана; план исполняет Sonnet | Неверное имя модели в трейлере 4–5 коммитов; правится при rebase |
|
||||
|
||||
## Наблюдения вне ревью
|
||||
|
||||
- O-1: README к началу исполнения утверждал «сейчас 9», хотя `AGENT_VERSION` был 10 (v10 не обновила пункт); исправлено вместе с обновлением до 11.
|
||||
- O-2: полный набор занимает ≈2 минуты (230 → 262 теста), дольше таймаута foreground-команды; прогоны `task-done` запускались в фоне.
|
||||
@ -0,0 +1,3 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-19
|
||||
skip_specs: true
|
||||
@ -0,0 +1,20 @@
|
||||
# agent-v12-project-boards — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35).
|
||||
Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки
|
||||
агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»;
|
||||
для агента — № 6, 7, 14); здесь — только решения агентской части.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` |
|
||||
| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) |
|
||||
| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба |
|
||||
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
|
||||
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
|
||||
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
|
||||
| R-36 | 2026-10-19 | По находке I-1 финального ревью: если scrum-доска не отдала спринты в `refresh`, агент перепроверяет её тип; стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}`, `refresh` `done`; иначе `failed` с текстом «доска <id>: …» (код и `retryable` прежние) | Смена доски на канбан лечится без правки базы; при прочих ошибках владелец видит, какую доску править (R-32 расширил зону поражения на все проекты) | Лишний `GET /board/{id}` при ошибке спринтов; типы досок, определённые в упавшем прогоне, не доходят до хаба — следующий `refresh` спросит снова |
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
@ -0,0 +1,19 @@
|
||||
# agent-v13-change-history — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/change-history/` (issue aep/jira-hub#70, aep/jira-agent#39).
|
||||
Агентская часть — решения 7–11 и 17 дизайна хаба и требование `agent` «Журнал изменений» (ADDED); контракт команд —
|
||||
`commands` «Журнал изменений в refresh» (`refresh.history`, результат `history`); приём — `hub-api` «Приём журнала
|
||||
изменений» (`POST /agent/history`). Версия агента — 13.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения хаба записаны в `jira-hub/openspec/changes/change-history/design.md` (раздел «Decisions made without
|
||||
discussion»); здесь — только решения агентской части, принятые при исполнении.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-3 | 2026-10-22 | Ошибка `GET /field` или дочитывания `GET /issue` относится к той части журнала, которую она прервала: в регулярной — `history.error`, в стартовой — `backfill_error` | Спека называет только `search` и отправку; ошибка чтения — свойство части, которую она прервала | Стартовая часть, упавшая на `GET /field`, не держит отметку — как задумано для разовой стартовой загрузки |
|
||||
| R-4 | 2026-10-22 | Ошибка одной части журнала не отменяет другую и отправку уже прочитанного | Меньше потерь; спека требует этого для обратного случая («регулярная часть при этом выполняется») | При `error` отметка не сдвинется, следующий прогон дочитает — дубли хаб отбросит |
|
||||
| R-5 | 2026-10-22 | Неразбираемый `history.since` — `history.error` с текстом, `refresh` остаётся `done` | Анти-цель «журнал не ломает обновление зеркала» | Нет: хаб шлёт `since` сам в одном формате |
|
||||
| R-6 | 2026-10-22 | Недоступность хаба только на `/agent/history` в тестах имитирует `FakeHub.reject_history = "drop"` (обрыв соединения без ответа) | Нужен обрыв именно на шаге журнала | Нет (тестовый фейк) |
|
||||
| R-7 | 2026-10-22 | По I-1 финального ревью: стартовая часть (`history.jql`) выполняется первой и берёт журнал целиком; регулярная не запрашивает уже прочитанные задачи; ключ помечается прочитанным только после разбора его журнала. Хаб: design #19 | Задача из обеих выборок теряла записи старше `since`; упавшая на задаче стартовая часть не должна «съедать» её для регулярной | Нет лишних вызовов Jira; при сбое стартовой на задаче регулярная добирает только записи после `since` |
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-22
|
||||
@ -0,0 +1,21 @@
|
||||
# agent-v14-attachment-fetch — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `jira-hub/openspec/changes/attachment-fetch/` (issue aep/jira-hub#83, aep/jira-agent#43).
|
||||
Агентская часть — решения 1, 4–6, 8, 17, 18 дизайна хаба; требования `agent` «Чтение состояния» (MODIFIED: поле
|
||||
`attachments` в состоянии, `attachment` в `search`) и «Скачивание вложения» (ADDED); контракт команды — `commands`
|
||||
«Скачивание вложения по запросу» (`fetch_attachment`); отправка файла — `hub-api` «Маршруты агента»
|
||||
(`POST /agent/files`). Версия агента — 14.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Решения хаба записаны в `jira-hub/openspec/changes/attachment-fetch/design.md` (раздел «Decisions made without
|
||||
discussion»); здесь — только решения агентской части, принятые при исполнении.
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-50 | 2026-10-22 | Вложение без `filename` в ответе Jira отбрасывается с логом, как без `id` | Хаб требует непустой `filename` (`hub-api`), иначе 422 на всю пачку состояния | Такое вложение не видно в мосту |
|
||||
| R-51 | 2026-10-22 | `size` вложения не целое, отрицательное или `bool` — передаётся `null` | Хаб принимает только неотрицательное целое или `null` | Нет |
|
||||
| R-52 | 2026-10-22 | Отправка файла хабу — `HubClient.upload_file` (в плане — `put_file`) | У `FakeHub` уже есть хелпер `put_file`; одно имя на разные действия путает | Нет |
|
||||
| R-53 | 2026-10-22 | Тесты `upload_file` через папку — в `tests/test_folder.py` | Там фикстура реле | Нет |
|
||||
| R-54 | 2026-10-22 | По M-4 финального ревью: запрет перенаправлений на другой адрес — для всех запросов агента (`Http.send` и `fetch_bytes` через `_opener`), а не только для скачивания; отказ в JSON-вызове — `HttpError 302` «перенаправление на <хост> отклонено» | Design хаба #6: учётные данные уходят только на `base_url`; `urllib` переносил `Authorization` на чужой хост в любом вызове | Если Jira банка штатно перенаправляет API на другой хост — вызовы падают с понятной ошибкой, видно на живой проверке |
|
||||
| R-55 | 2026-10-22 | По M-1: при неизвестном размере тело короче `Content-Length` — `ConnectionError` (повтор), а не файл | Иначе битый файл становится «скачанным» у хаба | Нет |
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-24
|
||||
@ -0,0 +1,81 @@
|
||||
# agent-v15-sprints — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `sdlc-hub/openspec/changes/sprints-backlog/` (issue aep/sdlc-hub#108, aep/jira-agent#49).
|
||||
Агентская часть: требование `agent` «Спринты, ранг и оценки» (ADDED); контракт команд — `commands` «Команда —
|
||||
желаемый результат на одной задаче» (MODIFIED: `story_points` в `ensure_fields`, `ensure_sprint_meta`,
|
||||
`ensure_sprint_state`, `ensure_rank`) и абзацы о командах спринтов; формат состояния — `hub-api` «Приём досок и
|
||||
спринтов» (ADDED). Версия агента — 15.
|
||||
|
||||
## Решения агента
|
||||
|
||||
1. **Поля Jira — по типу поля, один раз на процесс.** `GET /rest/api/2/field` при первой нужде, кэш на процесс:
|
||||
- ранг — поле с `schema.custom = "com.pyxis.greenhopper.jira:gh-lexo-rank"`;
|
||||
- спринт — поле с `schema.custom = "com.pyxis.greenhopper.jira:gh-sprint"`;
|
||||
- story points — поле с именем «Story Points» (без учёта регистра); несколько — первое по id.
|
||||
Перекрытие из настроек хаба: `field_ids.rank`, `field_ids.sprint`, `field_ids.story_points` (если заданы — поиск не
|
||||
нужен). Ошибка `GET /field` — поля считаются ненайденными, причина в лог; повтор — в следующем `refresh`.
|
||||
2. **`sprint_ids` — из поля спринта в том же `search`**, а не обходом спринтов: id поля спринта добавляется в `fields`
|
||||
поиска. Значение — список; элемент — объект с `id` (Jira 8+) или строка
|
||||
`com.atlassian.greenhopper.service.sprint.Sprint@…[id=72,…]` (Jira Server): id — из `id` или из `id=<n>` в строке;
|
||||
не разобралось — элемент пропускается с записью в лог. Поля нет — `sprint_ids` не передаётся (хаб сохранит
|
||||
прежнее). Имя текущего спринта (`sprint`) — как в v12 (обход активных и будущих спринтов досок), без изменений.
|
||||
3. **`rank`, `story_points` — тоже из `search`**: id полей добавляются в `fields`. `story_points` — число (`int` или
|
||||
`float`), иное — `null`; поле не найдено — `story_points` не передаётся у задач и запись в лог один раз.
|
||||
4. **Данные досок.** Для каждой доски `scrum`/`kanban` из `refresh.boards` (тип известный или только что определённый):
|
||||
- колонки — `GET /rest/agile/1.0/board/{id}/configuration` → `columnConfig.columns[]` `{name, statuses: [{id}]}`;
|
||||
id статусов переводятся в имена по `GET /rest/api/2/status` (кэш на процесс; неизвестный id — пропускается);
|
||||
- kanban-бэклог (только `kanban`): `GET /rest/agile/1.0/board/{id}/backlog?maxResults=0` — 200 → `true`, 400 → `false`,
|
||||
иное → `null`; проверить на Jira банка при выкатке;
|
||||
- спринты (только `scrum`): `GET /rest/agile/1.0/board/{id}/sprint` без фильтра состояния, постранично (50);
|
||||
элемент → `{id, name, state, start_date: startDate, end_date: endDate, complete_date: completeDate, goal}`;
|
||||
- ошибка чтения настроек доски — `columns: null`, `kanban_backlog: null`, спринты — если прочитались; причина в лог.
|
||||
`boards` прикладывается к первой пачке `POST /agent/state`; при пустой выборке — одна пачка `{issues: [], links: [],
|
||||
boards}`. Доски без данных (тип `null`) в `boards` не входят.
|
||||
5. **Даты команд спринта.** `start_date`/`end_date` (`ГГГГ-ММ-ДД`) → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` и
|
||||
`ГГГГ-ММ-ДДT18:00:00.000<смещение>`, смещение — локального времени агента (`datetime.now().astimezone().strftime("%z")`, вид `+0300`, R-68).
|
||||
Сравнение с Jira — по дате (первые 10 символов `startDate`/`endDate`).
|
||||
6. **`ensure_sprint_meta`.** Спринты доски `board_id` — все состояния (тот же обход, что в п. 4, кэш на команду);
|
||||
поиск по имени `sprint` (точное совпадение). Нет — `POST /rest/agile/1.0/sprint` `{name: rename_to or sprint,
|
||||
originBoardId, startDate?, endDate?, goal?}`; есть — `POST /rest/agile/1.0/sprint/{id}` только с отличающимися
|
||||
полями (`name` при `rename_to`, даты по п. 5, `goal`); нет отличий — `done` без записи. Ошибки — по контракту
|
||||
`commands`.
|
||||
7. **`ensure_sprint_state`.** `active`: спринт `future` — `POST /sprint/{id}` `{state: "active", startDate, endDate}`
|
||||
(из спринта; нет дат — `failed`). `closed`: колонки доски (п. 4) → набор статусов последней колонки; задачи спринта
|
||||
(`GET /sprint/{id}/issue?fields=status`, постранично) не в этих статусах — в `move_open_to` (`POST /sprint/{to}/issue`
|
||||
или `POST /backlog/issue`, по 50 ключей), затем `POST /sprint/{id}` `{state: "closed"}`. Колонки не прочитались —
|
||||
`failed` «не удалось прочитать колонки доски». Между записями — зазор типа команды, как в `ensure_link`.
|
||||
8. **`ensure_rank`.** Ранг обеих задач — `search` по `key in (key, other)` с полем ранга; нет поля ранга — `failed`
|
||||
«поле ранга не найдено»; уже выше/ниже (сравнение строк) — `done {changed: false}`; иначе
|
||||
`PUT /rest/agile/1.0/issue/rank` `{issues: [key], rankBeforeIssue | rankAfterIssue: other, rankCustomFieldId}` →
|
||||
`{changed: true}`.
|
||||
9. **`story_points` в `ensure_fields`.** Поле по п. 1; не найдено — `failed` «поле оценки не найдено в Jira: задайте
|
||||
field_ids.story_points». Сравнение чисел: равны, если `abs(a - b) < 1e-9`; `null` — очистка (`None` в `PUT`).
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|---|
|
||||
| R-60 | 2026-10-24 | Поля ранга, спринта, оценки — по типу/имени через `GET /field`, перекрытие `field_ids` (п. 1) | Владелец полей не знает, оценок в AURORA ещё нет | Не то поле «Story Points» — задать `field_ids.story_points` |
|
||||
| R-61 | 2026-10-24 | `sprint_ids` из поля спринта в `search`, разбор строкового вида Jira Server (п. 2) | Обход закрытых спринтов — N запросов на спринт; поле есть у каждой задачи | Формат поля банка иной — `sprint_ids` пропадут, видно на живой проверке |
|
||||
| R-62 | 2026-10-24 | Kanban-бэклог — по ответу `GET /board/{id}/backlog` (п. 4) | В `configuration` Jira Server явного признака нет | `null` — у kanban нет раздела «Бэклог» |
|
||||
| R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками |
|
||||
| R-64 | 2026-10-24 | Поле ранга/спринта/оценки не найдено — ключ `rank`/`sprint_ids`/`story_points` у задач не передаётся (design агента 2–3), а не `null` (спека `agent`: «значение null у всех задач») | Сбой `GET /field` с `null` стёр бы значения в зеркале; хаб без поля сохраняет прежнее, у новой базы прежнее — `NULL` | Поле удалили в Jira — в зеркале остаются старые значения до ручной правки |
|
||||
| R-65 | 2026-10-24 | Ошибка чтения спринтов доски — доска не входит в `boards` (хаб сохраняет прежние данные), причина в лог; ошибка чтения настроек при прочитанных спринтах — доска с `columns: null` | Доска из `boards` заменяет все свои спринты: `sprints: []` стёр бы историю; спека «прежние правила v12 … refresh продолжается» внутренне противоречива | Данные доски устаревают до следующего удачного `refresh` |
|
||||
| R-66 | 2026-10-24 | Пустая пачка с `boards` — только когда в параметрах есть `boards` (хаб v15) и выборка пуста (`jql` без задач, `keys: []`); все ключи 400 — по-прежнему `failed` (v14, `test_all_keys_400_fails`); `pages` считает и пустую пачку | Без `boards` пустая пачка бесполезна; «ни один ключ не найден» — признак ошибки конфигурации (R-7 v4) | Хвост `refresh` по ключам удалённых задач — `failed`, данные досок в этот раз не обновятся |
|
||||
| R-67 | 2026-10-24 | Любой `HttpError` на `GET /field` — поля не найдены до конца команды, следующая команда спрашивает снова; `ConnectionError` — как везде (повтор `refresh`); список полей один на команду и общий с журналом изменений (`field_id_of`) | design агента 1; один `GET /field` за `refresh` сохраняет `test_custom_field_lookup_once` | Нет |
|
||||
| R-68 | 2026-10-24 | Смещение дат спринта — `datetime.now().astimezone().strftime("%z")`, не `time.strftime("%z")` (design агента 5) | `time.strftime("%z")` на Windows отдаёт имя пояса, а не `+0300` | Нет |
|
||||
| R-69 | 2026-10-24 | `ensure_sprint_meta`: спринта `sprint` нет, а ровно один не закрытый спринт с именем `rename_to` есть — это он (повтор после переименования), правятся его поля; есть оба — «имя занято»; «несколько с именем» проверяется и для `rename_to` | Повтор команды не должен падать или создавать второй спринт (Anti-Goal proposal) | Нельзя создать новый спринт с именем существующего через `rename_to` — и не нужно |
|
||||
| R-70 | 2026-10-24 | У `ensure_sprint_meta` и `ensure_sprint_state` `board_id` только из команды, без запасного `board_id` настроек; нет — `failed` «board_id нет в команде — спринты недоступны» | Новые типы ставит только хаб v15, он всегда дописывает доску; чужая доска из настроек опаснее отказа | Нет |
|
||||
| R-71 | 2026-10-24 | Правило «несколько спринтов с именем» — и для `ensure_sprint_state`; `move_open_to` — активный или будущий спринт той же доски, не сам закрываемый, ровно один с этим именем; все проверки — до первой записи; спринт уже в нужном состоянии — `done` без проверки `move_open_to` | Повтор безопасен; ошибка параметров не оставляет спринт полузакрытым | Повтор с опечаткой в `move_open_to` после закрытия — `done`, опечатка не замечена |
|
||||
| R-72 | 2026-10-24 | Спринт доски без `id`/`name` или с состоянием вне `active`/`future`/`closed` (после `lower()`) — отбрасывается с записью в лог | Хаб отвергает всю пачку (`bad_state`), `refresh` упал бы целиком | Такой спринт не виден в вебе |
|
||||
| R-73 | 2026-10-24 | Ответ `PUT /issue/rank` с `entries[].status` не 2xx — `failed` «Jira не переранжировала <ключ>: <errors>» | Jira Agile сообщает о частичном отказе кодом 207 | Нет |
|
||||
| R-74 | 2026-10-24 | Порядок кандидатов полей `(len(id), id)` — числовой порядок `customfield_N`, для всех трёх полей | design агента 1 («первое по id») задан только для оценки, строковое сравнение ставит `customfield_9999` после `customfield_10050` | Не то поле — перекрытие `field_ids` |
|
||||
| R-75 | 2026-10-24 | Кэш полей и имён статусов — на экземпляр `Executor` (Runner пересоздаёт его при смене настроек) | «Один раз на процесс» с учётом смены `field_ids` | Нет |
|
||||
| R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов |
|
||||
| R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` |
|
||||
| R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет |
|
||||
| R-79 | 2026-10-24 | `ensure_fields`: настроенный `field_ids.story_points` читается напрямую, без `jira_fields()`; значение `story_points` проверяется до `GET /issue` | Результат совпадает с `jira_fields`; при частичном перекрытии не делается лишний `GET /field` (тест `test_story_points_override`) | Нет |
|
||||
| R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` |
|
||||
| R-81 | 2026-10-24 | `ensure_sprint_state`: задача без статуса в ответе — незавершённая; дубликаты имени цели переноса — ошибка «несколько спринтов с именем…»; зазор `gap` ставится и перед закрытием спринта, если до этого были переносы | Решения исполнителя Task 6; безопаснее перенести лишнее, чем потерять незавершённое | Задача без статуса, на деле готовая, уедет в следующий спринт |
|
||||
| R-82 | 2026-10-24 | `ensure_rank`: элемент `entries` с неуспешным статусом без `errors` даёт текст «статус <N>» | Текст для этого случая планом не задан; пустой хвост после двоеточия бесполезен | Нет |
|
||||
| R-83 | 2026-10-24 | `ensure_sprint_state`: «готово» определяется по id статусов последней колонки (из configuration) и `status.id` задач, а не по именам; пустой набор id — `failed`; в `refresh` неизвестный id статуса → один раз перечитать `/status`, затем лог | Необратимый перенос готовых задач при неполном/устаревшем кэше имён (C-1 финального ревью) | Ещё один `GET /status` при неизвестном id; отступление от п. 7 design по способу (имена → id), не по смыслу |
|
||||
@ -0,0 +1,36 @@
|
||||
# agent-v16-sp-estimation — решения агента
|
||||
|
||||
Спека и дизайн изменения — в `sdlc-hub/openspec/changes/sp-estimation-board/` (issue aep/sdlc-hub#113, aep/jira-agent#53).
|
||||
Агентская часть: требование `agent` «Спринты, ранг и оценки» (MODIFIED: оценка через доску) и строка `ensure_fields`
|
||||
в `commands` «Команда — желаемый результат на одной задаче» (MODIFIED: `board_id` при `story_points`, чтение и запись
|
||||
оценки через `/rest/agile/1.0/issue/{key}/estimation`). Версия агента — 16.
|
||||
|
||||
## Причина
|
||||
|
||||
Jira банка 25.10.2026 отклонила `PUT /rest/api/2/issue/{key}` с `customfield_15852`: «Field cannot be set. It is not on
|
||||
the appropriate screen, or unknown». Поле оценки не на экране редактирования; бэклог Scrum ставит его через доску.
|
||||
Владелец проверил в браузере: `GET /rest/agile/1.0/issue/AURORA-1704/estimation?boardId=4216` →
|
||||
`{"fieldId":"customfield_15852","value":1.0}`.
|
||||
|
||||
## Решения агента
|
||||
|
||||
1. **`ensure_fields` с `story_points` и `board_id`:** `GET AGILE/issue/{key}/estimation?boardId=<board_id>`, сравнение
|
||||
`_same_points(value, want)`; отличается — `PUT` туда же `{"value": want}` (`null` — очистить). `field_ids.story_points`
|
||||
и `GET /field` для этого не нужны.
|
||||
2. **Остальные поля — как v15**, `PUT /issue` только с ними; `GET /issue` — только если в команде есть поля, кроме
|
||||
оценки (`key`, `board_id`, `story_points` не считаются).
|
||||
3. **Порядок:** сначала `PUT /issue` с остальными полями, затем оценка. Ошибка оценки — исключение HTTP наружу, как у
|
||||
прочих вызовов Jira (команда `failed` с текстом Jira).
|
||||
4. **Без `board_id`** — оценка через `PUT /issue` в поле `field_ids.story_points`/найденное по имени, как v15.
|
||||
5. **Проверка значения** (`число ≥ 0 или null`) — до любых вызовов Jira, как v15.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
| Дата | Решение | Обоснование | Цена ошибки |
|
||||
|---|---|---|---|
|
||||
| 25.10.2026 | R-1: без `board_id` — путь v15 | совместимость с хабом до #113 | нет |
|
||||
| 25.10.2026 | R-2: сначала остальные поля, затем оценка | новый путь не должен ломать остальные поля | частичная запись, повтор безопасен |
|
||||
| 25.10.2026 | R-3: без `GET /issue`, если в команде только оценка | минус вызов Jira на команду в пакете оценок | нет |
|
||||
| 25.10.2026 | R-5: `value` — число как есть | Jira отдаёт число; живая проверка до пакета | одна правка агента |
|
||||
| 25.10.2026 | I-1 (финальное ревью): между `PUT /issue` и `PUT …/estimation` одной команды — зазор `ensure_fields` | политика темпа: зазор между записями внутри команды | пакет «оценка + поля» идёт медленнее |
|
||||
| 25.10.2026 | I-2 (финальное ревью): `fieldId` оценки доски сверяется с `field_ids.story_points` (если задан), расхождение — `failed` без записи | иначе число уйдёт в чужое поле доски другого проекта | лишний отказ, если поле настроено неверно — правится настройкой |
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"hub_url": "http://192.168.0.106:8790",
|
||||
"hub_token": "агентский токен из `jira-hub tokens`",
|
||||
"hub_token": "агентский токен из `sdlc-hub tokens`",
|
||||
"jira": {
|
||||
"base_url": "https://sdlc.go.rshbank.ru/jira",
|
||||
"auth": {"type": "basic", "user": "логин", "password": "пароль", "token": ""}
|
||||
|
||||
1052
src/agent.py
1052
src/agent.py
File diff suppressed because it is too large
Load Diff
@ -9,12 +9,14 @@ if SRC not in sys.path:
|
||||
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}
|
||||
"ensure_sprint": 0, "ensure_sprint_meta": 0, "ensure_sprint_state": 0, "ensure_rank": 0,
|
||||
"ensure_attachment": 0, "fetch_attachment": 0, "refresh": 0}
|
||||
|
||||
SETTINGS = {
|
||||
"base_jql": "project = AURORA AND labels = AWPCA",
|
||||
"project_key": "AURORA",
|
||||
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"}},
|
||||
"issue_types": {"task": {"name": "Задача", "id": "10100"}, "epic": {"name": "Фича", "id": "12300"},
|
||||
"bug": {"name": "Ошибка", "id": None}},
|
||||
"epic_name_field_id": "customfield_15304",
|
||||
"field_ids": {"description": "description", "priority": "priority", "duedate": "duedate", "labels": "labels",
|
||||
"planned_start": "customfield_10801", "planned_end": "customfield_10802"},
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@ -23,7 +26,14 @@ class FakeHub:
|
||||
self.in_flight = None
|
||||
self.release = None # {"version", "sha256", "content"} — опубликованная версия агента
|
||||
self.release_requests = 0
|
||||
self.files = {} # sha256 → содержимое (хранилище файлов вложений хаба)
|
||||
self.file_requests = 0
|
||||
self.claim_wait = 0 # с: при пустой очереди держать claim, как хаб
|
||||
self.histories = [] # тела POST /agent/history
|
||||
self.reject_history = None # (код, тело) — ответ на /agent/history ошибкой; "drop" — оборвать без ответа
|
||||
self.posts = [] # порядок POST /agent/state и /agent/history
|
||||
self.uploaded = [] # содержимое, присланное агентом POST /agent/files
|
||||
self.reject_files = None # (код, тело) — ответ на POST /agent/files ошибкой; "drop" — оборвать без ответа
|
||||
self.lock = threading.Lock()
|
||||
hub = self
|
||||
|
||||
@ -54,6 +64,19 @@ class FakeHub:
|
||||
self.queue.append(cid)
|
||||
return cid
|
||||
|
||||
def put_file(self, data):
|
||||
sha = hashlib.sha256(data).hexdigest()
|
||||
self.files[sha] = data
|
||||
return sha
|
||||
|
||||
def _issued(self, c):
|
||||
"""Параметры при выдаче: как хаб, к ensure_attachment дописывается size файла (None — файла нет)."""
|
||||
params = dict(c["params"])
|
||||
if c["type"] == "ensure_attachment":
|
||||
data = self.files.get(params.get("file"))
|
||||
params["size"] = len(data) if data is not None else None
|
||||
return params
|
||||
|
||||
def _release_ref(self):
|
||||
r = self.release
|
||||
return {"version": r["version"], "sha256": r["sha256"]} if r else None
|
||||
@ -64,6 +87,14 @@ class FakeHub:
|
||||
if h.headers.get("Authorization") != "Bearer " + self.token:
|
||||
return self._send(h, 401, {"error": "unauthorized"})
|
||||
path = h.path.split("?")[0]
|
||||
if path == "/agent/files" and method == "POST" and self.reject_files == "drop":
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR)
|
||||
return
|
||||
if path == "/agent/history" and self.reject_history == "drop":
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # хаб недоступен: ответа клиент не получит
|
||||
return
|
||||
if path == "/agent/claim" and self.claim_wait:
|
||||
end = time.monotonic() + self.claim_wait
|
||||
while time.monotonic() < end: # ждать без блокировки: другие запросы (enqueue) проходят
|
||||
@ -97,7 +128,7 @@ class FakeHub:
|
||||
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"]},
|
||||
return 200, {"command": {"id": cid, "type": c["type"], "params": self._issued(c), "pause_seconds": c["pause_seconds"]},
|
||||
"settings_version": self.settings["settings_version"], "agent_release": self._release_ref()}
|
||||
if path == "/agent/release":
|
||||
self.release_requests += 1
|
||||
@ -106,13 +137,33 @@ class FakeHub:
|
||||
return 200, self.settings
|
||||
if path == "/agent/known-keys":
|
||||
return 200, {"keys": list(self.known)}
|
||||
if path == "/agent/history":
|
||||
self.posts.append(path)
|
||||
if self.reject_history:
|
||||
return self.reject_history
|
||||
self.histories.append(body)
|
||||
return 200, {"accepted": len(body.get("changes") or []), "duplicates": 0}
|
||||
if path == "/agent/state":
|
||||
self.posts.append(path)
|
||||
self.states.append(body)
|
||||
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
|
||||
if path == "/agent/files" and method == "POST":
|
||||
if self.reject_files:
|
||||
return self.reject_files
|
||||
data = base64.b64decode(body.get("content") or "", validate=True)
|
||||
self.uploaded.append(data)
|
||||
return 200, {"file": self.put_file(data), "size": len(data)}
|
||||
m = re.match(r"^/agent/files/([^/]+)$", path)
|
||||
if m and method == "GET":
|
||||
self.file_requests += 1
|
||||
data = self.files.get(m.group(1))
|
||||
if data is None:
|
||||
return 404, {"error": "no_file", "detail": "файла %s нет в хранилище" % m.group(1)}
|
||||
return 200, {"file": m.group(1), "size": len(data), "content": base64.b64encode(data).decode("ascii")}
|
||||
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"})
|
||||
return (200, dict(c, params=self._issued(c))) if c else (404, {"error": "not_found"})
|
||||
m = re.match(r"^/agent/commands/(\d+)/result$", path)
|
||||
if m:
|
||||
cid = int(m.group(1))
|
||||
|
||||
@ -6,7 +6,7 @@ import socket
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
MSK = timezone(timedelta(hours=3))
|
||||
|
||||
@ -21,7 +21,50 @@ WORKFLOW = { # статус → [(имя перехода, целевой ст
|
||||
}
|
||||
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
|
||||
{"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}]
|
||||
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304")
|
||||
SPRINT_FIELD = "customfield_10104"
|
||||
RANK_FIELD = "customfield_10105"
|
||||
POINTS_FIELD = "customfield_10106"
|
||||
STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ", "10003": "Разработка",
|
||||
"10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"}
|
||||
COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])]
|
||||
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD)
|
||||
|
||||
|
||||
def parse_multipart(raw, content_type):
|
||||
"""Строгий разбор multipart/form-data, как commons-fileupload Jira Server с кодировкой заголовков UTF-8:
|
||||
[{"headers": {имя в нижнем регистре: значение}, "data": bytes}]. Нарушение формата — AssertionError (фейк ответит 400)."""
|
||||
m = re.search(r"boundary=([^;\s]+)", content_type)
|
||||
assert m, "нет boundary в Content-Type"
|
||||
delim = b"--" + m.group(1).encode("ascii")
|
||||
head, tail = delim + b"\r\n", b"\r\n" + delim + b"--\r\n"
|
||||
assert raw.startswith(head), "тело не начинается с границы"
|
||||
assert raw.endswith(tail), "нет закрывающей границы"
|
||||
parts = []
|
||||
for chunk in raw[len(head):len(raw) - len(tail)].split(b"\r\n" + delim + b"\r\n"):
|
||||
hdr, sep, data = chunk.partition(b"\r\n\r\n")
|
||||
assert sep, "у части нет пустой строки после заголовков"
|
||||
headers = {}
|
||||
for line in hdr.decode("utf-8").split("\r\n"):
|
||||
name, _, value = line.partition(":")
|
||||
headers[name.strip().lower()] = value.strip()
|
||||
parts.append({"headers": headers, "data": data})
|
||||
return parts
|
||||
|
||||
|
||||
def disposition_params(value):
|
||||
"""Параметры Content-Disposition части: name; filename — как в кавычках, без percent-декодирования (так читает
|
||||
Jira Server); filename* — после UTF-8'' с percent-декодированием."""
|
||||
out = {}
|
||||
m = re.search(r'(?:^|;)\s*name="([^"]*)"', value)
|
||||
if m:
|
||||
out["name"] = m.group(1)
|
||||
m = re.search(r'(?:^|;)\s*filename="([^"]*)"', value)
|
||||
if m:
|
||||
out["filename"] = m.group(1)
|
||||
m = re.search(r"(?:^|;)\s*filename\*=UTF-8''([^;\s]+)", value)
|
||||
if m:
|
||||
out["filename*"] = unquote(m.group(1), encoding="utf-8", errors="strict")
|
||||
return out
|
||||
|
||||
|
||||
class FakeJira:
|
||||
@ -32,12 +75,55 @@ class FakeJira:
|
||||
self.comments = {}
|
||||
self.links = []
|
||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
||||
self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
|
||||
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
|
||||
self.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS)
|
||||
self.statuses = dict(STATUSES) # id → имя статуса (GET /status); тесты могут добавить статус
|
||||
self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200)
|
||||
self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им
|
||||
self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper
|
||||
self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи)
|
||||
self.sprint_seq = 500 # id следующего созданного спринта
|
||||
self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id}
|
||||
self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank
|
||||
self.estimation_bodies = [] # тела PUT /rest/agile/1.0/issue/{key}/estimation
|
||||
self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется
|
||||
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.comment_denied = {} # ключ → код ответа на GET /issue/{key}/comment (403 — нет прав, 404 — нет задачи)
|
||||
self.attachments = {} # ключ → [{id, filename, size, mimeType, data}]
|
||||
self.attachment_seq = 20000
|
||||
self.uploads = [] # разобранные POST /attachments: {key, token, content_type, headers, disposition}
|
||||
self.drop_next_post_attachment = False # сохранить вложение и оборвать соединение без ответа
|
||||
self.attachment_limit = None # байт: больше — 413, как предел вложений Jira
|
||||
self.bad_attachment_response = False # ответить на загрузку пустым списком
|
||||
self.hide_size = set() # id вложений без size в JSON (Jira не отдала размер)
|
||||
self.hide_attachment_field = False # поле attachment скрыто настройками Jira
|
||||
self.content_gets = 0 # GET /secure/attachment/<id>/<имя> — запросы содержимого
|
||||
self.redirect_content = None # адрес для 302 на запрос содержимого (абсолютный или путь этого сервера)
|
||||
self.short_content = set() # id вложений, содержимое которых отдаётся без последнего байта
|
||||
self.lying_length = set() # id вложений: Content-Length больше тела, соединение рвётся после тела
|
||||
self.redirects = {} # путь → адрес Location: 302 вместо ответа (любой метод)
|
||||
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
||||
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
||||
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
|
||||
self.history_seq = 50000
|
||||
self.changelog_cap = None # сколько записей журнала отдаёт search и GET /issue (None — все); total — полное
|
||||
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
|
||||
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
|
||||
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
|
||||
{"id": SPRINT_FIELD, "name": "Sprint", "custom": True,
|
||||
"schema": {"type": "array", "items": "string",
|
||||
"custom": "com.pyxis.greenhopper.jira:gh-sprint"}},
|
||||
{"id": RANK_FIELD, "name": "Rank", "custom": True,
|
||||
"schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-lexo-rank"}},
|
||||
{"id": POINTS_FIELD, "name": "Story Points", "custom": True,
|
||||
"schema": {"type": "number", "custom": "com.atlassian.jira.plugin.system.customfieldtypes:float"}},
|
||||
{"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field
|
||||
self.lock = threading.Lock()
|
||||
fake = self
|
||||
|
||||
@ -76,7 +162,7 @@ class FakeJira:
|
||||
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
||||
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
||||
"issuetype": "Задача", "created": self.ts(self.now - timedelta(hours=1)), "updated": "2026-10-01T10:00:00.000+0300",
|
||||
"sprint": None}
|
||||
"sprint": None, "sprint_ids": None}
|
||||
self.id_seq += 1
|
||||
base["id"] = str(self.id_seq)
|
||||
base.update({c: None for c in CUSTOM})
|
||||
@ -84,6 +170,45 @@ class FakeJira:
|
||||
self.issues[key] = base
|
||||
return base
|
||||
|
||||
def add_history(self, key, created, items, hid=None, name="petrov", display="Петров Пётр"):
|
||||
"""Запись журнала задачи; возвращает её id."""
|
||||
self.history_seq += 1
|
||||
hid = hid or str(self.history_seq)
|
||||
self.changelogs.setdefault(key, []).append({"id": hid, "author": {"name": name, "displayName": display},
|
||||
"created": created, "items": items})
|
||||
return hid
|
||||
|
||||
def changelog_json(self, key, cap):
|
||||
lst = self.changelogs.get(key, [])
|
||||
shown = lst if cap is None else lst[:cap]
|
||||
return {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), "histories": shown}
|
||||
|
||||
def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
|
||||
updated=None):
|
||||
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
|
||||
self.comment_seq += 1
|
||||
c = {"id": str(self.comment_seq), "body": body, "author": author, "created": created, "updated": updated or created}
|
||||
self.comments.setdefault(key, []).append(c)
|
||||
return c
|
||||
|
||||
def add_attachment(self, key, filename, data, mime="application/octet-stream", name="potapova", display="Потапова Анна",
|
||||
created="2026-10-20T09:15:00.000+0300"):
|
||||
self.attachment_seq += 1
|
||||
author = {"name": name, "displayName": display} if display else ({"name": name} if name else None)
|
||||
a = {"id": str(self.attachment_seq), "filename": filename, "size": len(data), "mimeType": mime, "data": data,
|
||||
"author": author, "created": created}
|
||||
self.attachments.setdefault(key, []).append(a)
|
||||
return a
|
||||
|
||||
def attachment_json(self, a):
|
||||
out = {"self": "%s/rest/api/2/attachment/%s" % (self.base_url, a["id"]), "id": a["id"],
|
||||
"filename": a["filename"], "size": a["size"], "mimeType": a["mimeType"], "author": a.get("author"),
|
||||
"created": a.get("created"),
|
||||
"content": "%s/secure/attachment/%s/%s" % (self.base_url, a["id"], quote(a["filename"]))}
|
||||
if a["id"] in self.hide_size:
|
||||
del out["size"]
|
||||
return out
|
||||
|
||||
def writes(self):
|
||||
return [r for r in self.requests if r[0] in ("POST", "PUT", "DELETE")]
|
||||
|
||||
@ -91,14 +216,40 @@ class FakeJira:
|
||||
def _user(self, name):
|
||||
return {"name": name, "displayName": name} if name else None
|
||||
|
||||
def issue_json(self, key):
|
||||
def issue_json(self, key, fields=None):
|
||||
"""fields — список запрошенных полей (как параметр fields у search); None — все поля, как GET /issue/{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}
|
||||
out = {"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)}
|
||||
out.update({c: f.get(c) for c in CUSTOM})
|
||||
out[SPRINT_FIELD] = self.sprint_value(f)
|
||||
if fields is None or "comment" in fields:
|
||||
lst = self.comments.get(key, [])
|
||||
cap = self.search_comment_cap if fields is not None else None
|
||||
shown = lst if cap is None else lst[:cap]
|
||||
out["comment"] = {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst),
|
||||
"comments": shown}
|
||||
if (fields is None or "attachment" in fields) and not self.hide_attachment_field:
|
||||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||||
return {"key": key, "id": f["id"], "fields": out}
|
||||
|
||||
def sprint_value(self, f):
|
||||
"""Значение поля спринтов задачи: sprint_raw как есть; иначе спринты по sprint_ids, иначе по имени sprint."""
|
||||
if "sprint_raw" in f:
|
||||
return f["sprint_raw"]
|
||||
allsp = self.all_sprints()
|
||||
if f.get("sprint_ids") is not None:
|
||||
sps = [s for i in f["sprint_ids"] for s in allsp if s["id"] == i]
|
||||
else:
|
||||
sps = [s for s in allsp if s["name"] == f.get("sprint")][:1] if f.get("sprint") else []
|
||||
if not sps:
|
||||
return None
|
||||
if self.sprint_field_format == "string":
|
||||
return ["com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s,"
|
||||
"sequence=%d]" % (s["id"], s["state"].upper(), s["name"], s["id"]) for s in sps]
|
||||
return [{"id": s["id"], "name": s["name"], "state": s["state"]} for s in sps]
|
||||
|
||||
def links_of(self, key):
|
||||
out = []
|
||||
@ -110,6 +261,8 @@ class FakeJira:
|
||||
return out
|
||||
|
||||
def search_keys(self, jql):
|
||||
if jql in self.jql_results:
|
||||
return [k for k in self.jql_results[jql] if k in self.issues]
|
||||
m = re.search(r"key in \(([^)]*)\)", jql or "")
|
||||
if m:
|
||||
keys = [k.strip() for k in m.group(1).split(",")]
|
||||
@ -132,26 +285,80 @@ class FakeJira:
|
||||
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 {}
|
||||
raw = h.rfile.read(length) if length else b""
|
||||
ctype = h.headers.get("Content-Type") or ""
|
||||
if ctype.startswith("multipart/form-data"):
|
||||
body = {"_raw": raw, "_content_type": ctype} # разбирает маршрут: ошибка формата — 400
|
||||
else:
|
||||
body = json.loads(raw.decode("utf-8")) if raw else {}
|
||||
with self.lock:
|
||||
self.last_headers = {k.lower(): v for k, v in h.headers.items()}
|
||||
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)
|
||||
if self.fail_paths.get(url.path):
|
||||
code = self.fail_paths[url.path].pop(0)
|
||||
return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, {})
|
||||
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 url.path in self.redirects:
|
||||
h.send_response(302)
|
||||
h.send_header("Location", self.redirects[url.path])
|
||||
h.send_header("Content-Length", "0")
|
||||
h.end_headers()
|
||||
return
|
||||
if method == "GET" and (url.path.startswith("/secure/attachment/") or url.path.startswith("/secure/moved/")):
|
||||
return self._content(h, url.path)
|
||||
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
|
||||
if self.drop_next_post_attachment and method == "POST" and url.path.endswith("/attachments"):
|
||||
self.drop_next_post_attachment = False
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # вложение сохранено, ответа клиент не получит
|
||||
return
|
||||
self._send(h, code, payload)
|
||||
|
||||
def _content(self, h, path):
|
||||
"""Содержимое вложения, как /secure/attachment/<id>/<имя> Jira: байты, не JSON. /secure/moved/… — цель 302."""
|
||||
with self.lock:
|
||||
m = re.match(r"^/secure/(attachment|moved)/(\d+)/(.+)$", path)
|
||||
found = None
|
||||
if m:
|
||||
for lst in self.attachments.values():
|
||||
for a in lst:
|
||||
if a["id"] == m.group(2) and a["filename"] == unquote(m.group(3)):
|
||||
found = a
|
||||
redirect = self.redirect_content if m and m.group(1) == "attachment" else None
|
||||
if found is not None and redirect is None:
|
||||
self.content_gets += 1
|
||||
if redirect is not None:
|
||||
h.send_response(302)
|
||||
h.send_header("Location", redirect)
|
||||
h.send_header("Content-Length", "0")
|
||||
h.end_headers()
|
||||
return
|
||||
if found is None:
|
||||
return self._send(h, 404, {"errorMessages": ["нет вложения"]})
|
||||
data = found["data"][:-1] if found["id"] in self.short_content else found["data"]
|
||||
h.send_response(200)
|
||||
h.send_header("Content-Type", found["mimeType"])
|
||||
h.send_header("Content-Length", str(len(data) + (60 if found["id"] in self.lying_length else 0)))
|
||||
h.end_headers()
|
||||
h.wfile.write(data)
|
||||
if found["id"] in self.lying_length:
|
||||
h.wfile.flush()
|
||||
h.close_connection = True
|
||||
h.connection.shutdown(socket.SHUT_RDWR) # обрыв посреди тела
|
||||
|
||||
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)
|
||||
@ -173,7 +380,14 @@ class FakeJira:
|
||||
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]}
|
||||
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
|
||||
issues = [self.issue_json(k, fields) for k in page]
|
||||
if q.get("expand") == "changelog":
|
||||
for it in issues:
|
||||
it["changelog"] = self.changelog_json(it["key"], self.changelog_cap)
|
||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": issues}
|
||||
if p == "/rest/api/2/field":
|
||||
return 200, self.fields
|
||||
if p == "/rest/api/2/issue" and method == "POST":
|
||||
self.seq += 1
|
||||
key = "%s-%d" % (self.project, self.seq)
|
||||
@ -201,7 +415,11 @@ class FakeJira:
|
||||
if m:
|
||||
key = m.group(1)
|
||||
if method == "GET":
|
||||
return 200, self.issue_json(key)
|
||||
out = self.issue_json(key)
|
||||
if q.get("expand") == "changelog":
|
||||
cap = self.issue_changelog_cap if self.issue_changelog_cap is not None else self.changelog_cap
|
||||
out["changelog"] = self.changelog_json(key, cap)
|
||||
return 200, out
|
||||
if method == "PUT":
|
||||
f = self.issues[key]
|
||||
for k, v in body.get("fields", {}).items():
|
||||
@ -216,13 +434,17 @@ class FakeJira:
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment$", p)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
if method == "GET" and key in self.comment_denied:
|
||||
return self.comment_denied[key], {"errorMessages": ["Нет прав на просмотр комментариев"]}
|
||||
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"]})
|
||||
ts = self.ts(self.now)
|
||||
lst.append({"id": str(self.comment_seq), "body": body["body"], "author": self._user("me"),
|
||||
"created": ts, "updated": ts})
|
||||
return 201, lst[-1]
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/comment/(\d+)$", p)
|
||||
if m and method == "PUT":
|
||||
@ -230,6 +452,26 @@ class FakeJira:
|
||||
if c["id"] == m.group(2):
|
||||
c["body"] = body["body"]
|
||||
return 200, {}
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/attachments$", p)
|
||||
if m and method == "POST":
|
||||
key = m.group(1)
|
||||
self.issues[key]
|
||||
if self.last_headers.get("x-atlassian-token") != "no-check":
|
||||
return 403, {"errorMessages": ["XSRF check failed"]} # как Jira Server без заголовка
|
||||
parts = parse_multipart(body["_raw"], body["_content_type"])
|
||||
assert len(parts) == 1, "ожидалась одна часть, пришло %d" % len(parts)
|
||||
part = parts[0]
|
||||
disp = disposition_params(part["headers"].get("content-disposition", ""))
|
||||
assert disp.get("name") == "file", "часть называется не file: %r" % disp
|
||||
self.uploads.append({"key": key, "token": self.last_headers.get("x-atlassian-token"),
|
||||
"content_type": body["_content_type"], "headers": part["headers"], "disposition": disp})
|
||||
if self.attachment_limit is not None and len(part["data"]) > self.attachment_limit:
|
||||
return 413, {"errorMessages": ["Размер вложения превышает предел %d байт" % self.attachment_limit]}
|
||||
a = self.add_attachment(key, disp["filename"], part["data"],
|
||||
part["headers"].get("content-type") or "application/octet-stream")
|
||||
if self.bad_attachment_response:
|
||||
return 200, []
|
||||
return 200, [self.attachment_json(a)]
|
||||
m = re.match(r"^/rest/api/2/issue/([^/]+)/transitions$", p)
|
||||
if m:
|
||||
key = m.group(1)
|
||||
@ -239,36 +481,135 @@ class FakeJira:
|
||||
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+)$", p)
|
||||
if m:
|
||||
board = int(m.group(1))
|
||||
if board not in self.boards:
|
||||
return 404, {"errorMessages": ["Board does not exist"]}
|
||||
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
|
||||
if p == "/rest/api/2/status":
|
||||
return 200, [{"id": i, "name": n} for i, n in self.statuses.items()]
|
||||
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p)
|
||||
if m:
|
||||
board = int(m.group(1))
|
||||
if board not in self.boards:
|
||||
return 404, {"errorMessages": ["Board does not exist"]}
|
||||
cols = self.board_columns.get(board, COLUMNS)
|
||||
return 200, {"id": board, "columnConfig": {"columns": [
|
||||
{"name": n, "statuses": [{"id": i} for i in ids]} for n, ids in cols]}}
|
||||
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/backlog$", p)
|
||||
if m:
|
||||
code = self.backlog_codes.get(int(m.group(1)), 200)
|
||||
if code != 200:
|
||||
return code, {"errorMessages": ["имитация ошибки"]}
|
||||
return 200, {"startAt": 0, "maxResults": 0, "total": 0, "issues": []}
|
||||
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]
|
||||
board = int(m.group(1))
|
||||
if board not in self.boards:
|
||||
return 404, {"errorMessages": ["Board does not exist"]}
|
||||
if self.boards.get(board) == "kanban":
|
||||
return 400, {"errorMessages": ["The board does not support sprints"]}
|
||||
states = q["state"].split(",") if q.get("state") else None # без state — спринты всех состояний
|
||||
vals = [s for s in self.board_sprints.get(board, self.sprints) if states is None or 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)}
|
||||
if p == "/rest/agile/1.0/sprint" and method == "POST":
|
||||
self.sprint_posts.append((p, body))
|
||||
self.sprint_seq += 1
|
||||
sp = {"id": self.sprint_seq, "name": body["name"], "state": "future", "originBoardId": body["originBoardId"]}
|
||||
sp.update({k: body[k] for k in ("startDate", "endDate", "goal") if k in body})
|
||||
target = self.board_sprints[body["originBoardId"]] if body["originBoardId"] in self.board_sprints else self.sprints
|
||||
target.append(sp)
|
||||
return 201, sp
|
||||
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)$", p)
|
||||
if m and method == "POST":
|
||||
self.sprint_posts.append((p, body))
|
||||
sp = next((s for s in self.all_sprints() if s["id"] == int(m.group(1))), None)
|
||||
if sp is None:
|
||||
return 404, {"errorMessages": ["Sprint does not exist"]}
|
||||
if "state" in body:
|
||||
st = body["state"]
|
||||
dated = bool(body.get("startDate", sp.get("startDate")) and body.get("endDate", sp.get("endDate")))
|
||||
if (st == "active" and (sp["state"] != "future" or not dated)) or (st == "closed" and sp["state"] != "active"):
|
||||
return 400, {"errorMessages": ["недопустимый переход состояния спринта"]}
|
||||
sp.update({k: body[k] for k in ("name", "startDate", "endDate", "goal", "state") if k in body})
|
||||
return 200, sp
|
||||
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)))
|
||||
sp = next(s for s in self.all_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"]]
|
||||
members = self.sprint_members.get(sp["id"])
|
||||
pool = list(self.issues) if "jql" not in q else self.search_keys(q.get("jql") or "key in ()")
|
||||
keys = [k for k in pool
|
||||
if (k in members if members is not None else 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]]}
|
||||
"issues": [{"key": k, "fields": {"status": self.status_json(self.issues[k]["status"])}}
|
||||
for k in keys[start:start + maxr]]}
|
||||
for k in body["issues"]:
|
||||
self.issues[k]["sprint"] = sp["name"]
|
||||
if self.issues[k]["sprint_ids"] is not None:
|
||||
self.issues[k]["sprint_ids"].append(sp["id"])
|
||||
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
|
||||
if p == "/rest/agile/1.0/issue/rank" and method == "PUT":
|
||||
return self._rank(body)
|
||||
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)/estimation$", p)
|
||||
if m: # оценка задачи на доске boardId (поле оценки доски — POINTS_FIELD)
|
||||
if m.group(1) not in self.issues:
|
||||
return 404, {"errorMessages": ["Issue Does Not Exist"]}
|
||||
if int(q.get("boardId") or 0) not in self.boards:
|
||||
return 400, {"errorMessages": ["The board %s does not exist" % q.get("boardId")]}
|
||||
issue = self.issues[m.group(1)]
|
||||
if method == "PUT":
|
||||
self.estimation_bodies.append(body)
|
||||
issue[POINTS_FIELD] = body.get("value")
|
||||
return 200, {"fieldId": POINTS_FIELD, "value": issue.get(POINTS_FIELD)}
|
||||
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)
|
||||
sp = next((s for s in self.all_sprints() if s["name"] == name), None)
|
||||
if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет
|
||||
return 200, {"key": m.group(1)}
|
||||
return 200, {"key": m.group(1), "fields": {"sprint": sp}}
|
||||
return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]}
|
||||
|
||||
def _rank(self, body):
|
||||
"""PUT /issue/rank: задача из issues встаёт строго выше (rankBeforeIssue) или ниже (rankAfterIssue) задачи-ориентира,
|
||||
ранг которой — ранг RANK_FIELD. Задача из rank_errors не меняется и получает status 400 в entries (ответ 207)."""
|
||||
self.rank_bodies.append(body)
|
||||
entries, failed = [], False
|
||||
for k in body["issues"]:
|
||||
if k in self.rank_errors:
|
||||
failed = True
|
||||
entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 400,
|
||||
"errors": [self.rank_errors[k]]})
|
||||
continue
|
||||
if body.get("rankBeforeIssue"):
|
||||
ref = self.issues[body["rankBeforeIssue"]][RANK_FIELD]
|
||||
self.issues[k][RANK_FIELD] = ref[:-1] + chr(ord(ref[-1]) - 1) + "z"
|
||||
else:
|
||||
self.issues[k][RANK_FIELD] = self.issues[body["rankAfterIssue"]][RANK_FIELD] + "m"
|
||||
entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 204, "errors": []})
|
||||
if failed:
|
||||
return 207, {"entries": entries}
|
||||
return 204, None
|
||||
|
||||
def status_json(self, name):
|
||||
"""{id, name} статуса по имени; имени нет в self.statuses — статус без id (как неполный ответ Jira)."""
|
||||
ids = [i for i, n in self.statuses.items() if n == name]
|
||||
return {"id": ids[0], "name": name} if ids else {"name": name}
|
||||
|
||||
def all_sprints(self):
|
||||
out = list(self.sprints)
|
||||
for vals in self.board_sprints.values():
|
||||
out += vals
|
||||
return out
|
||||
|
||||
def _type_name(self, ref):
|
||||
ref = ref or {}
|
||||
if ref.get("name"):
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
import urllib.parse
|
||||
|
||||
from _common import agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
@ -21,6 +22,138 @@ class ClientsTest(unittest.TestCase):
|
||||
self.assertIsNotNone(self.jira.last_call_at)
|
||||
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
|
||||
|
||||
def test_multipart_file_bytes(self):
|
||||
body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny")
|
||||
q = urllib.parse.quote("Отчёт.txt", safe="")
|
||||
expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; '
|
||||
"filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \
|
||||
+ b"x\r\ny" + b"\r\n--b0--\r\n"
|
||||
self.assertEqual(body, expected)
|
||||
|
||||
def test_wire_filename(self):
|
||||
self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt")
|
||||
self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx")
|
||||
|
||||
def test_upload_cyrillic_name(self):
|
||||
self.fake.add("AURORA-1")
|
||||
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||||
data = bytes(range(256)) * 40
|
||||
resp = self.jira.upload("AURORA-1", name, data)
|
||||
self.assertIsInstance(resp, list)
|
||||
a = self.fake.attachments["AURORA-1"][0]
|
||||
self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data))
|
||||
up = self.fake.uploads[-1]
|
||||
self.assertEqual(up["token"], "no-check")
|
||||
self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary="))
|
||||
self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex
|
||||
self.assertEqual(up["headers"]["content-type"], "application/octet-stream")
|
||||
self.assertEqual(up["disposition"]["filename*"], name)
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments"))
|
||||
|
||||
def test_upload_filename_with_quote_and_newline(self):
|
||||
self.fake.add("AURORA-1")
|
||||
name = 'итог "кв.2"\r\n.txt'
|
||||
self.jira.upload("AURORA-1", name, b"abc")
|
||||
up = self.fake.uploads[-1]
|
||||
self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt")
|
||||
self.assertEqual(up["disposition"]["filename*"], name)
|
||||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||||
self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc")
|
||||
|
||||
def test_upload_needs_xsrf_header(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"}
|
||||
with self.assertRaises(agent.HttpError) as cm:
|
||||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||||
self.assertEqual(cm.exception.code, 403)
|
||||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||||
|
||||
def test_upload_retries_429(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||||
self.assertEqual(self.jira.rate_limited, 1)
|
||||
self.assertEqual(self.jira.calls, 2)
|
||||
|
||||
def test_upload_timeout_at_least_120(self):
|
||||
self.fake.add("AURORA-1")
|
||||
seen = []
|
||||
orig = self.jira.send
|
||||
|
||||
def spy(*a, **kw):
|
||||
seen.append(kw.get("timeout"))
|
||||
return orig(*a, **kw)
|
||||
|
||||
self.jira.send = spy
|
||||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||||
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
|
||||
|
||||
def _content_path(self, a):
|
||||
return "/secure/attachment/%s/%s" % (a["id"], urllib.parse.quote(a["filename"], safe=""))
|
||||
|
||||
def test_download_bytes(self):
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "скрин 1.png", b"\x89PNG\x00\x01", mime="image/png")
|
||||
data = self.jira.download(self._content_path(a), 100)
|
||||
self.assertEqual(data, b"\x89PNG\x00\x01")
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
self.assertTrue(self.fake.last_headers.get("authorization", "").startswith("Basic "))
|
||||
self.assertEqual(self.fake.content_gets, 1)
|
||||
|
||||
def test_download_redirect_same_host(self):
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
|
||||
self.fake.redirect_content = "%s/secure/moved/%s/a.bin" % (self.fake.base_url, a["id"])
|
||||
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
|
||||
|
||||
def test_download_redirect_other_host(self):
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
|
||||
port = self.fake.server.server_port
|
||||
self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.bin" % (port, a["id"])
|
||||
with self.assertRaises(agent.RedirectAway) as e:
|
||||
self.jira.download(self._content_path(a), 100)
|
||||
self.assertEqual(e.exception.host, "localhost")
|
||||
self.assertEqual(self.fake.content_gets, 0)
|
||||
self.assertFalse(any(r[1].startswith("/secure/moved/") for r in self.fake.requests))
|
||||
|
||||
def test_download_limit(self):
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 20)
|
||||
with self.assertRaises(agent.TooBig):
|
||||
self.jira.download(self._content_path(a), 10)
|
||||
self.assertEqual(self.jira.download(self._content_path(a), 20), b"x" * 20)
|
||||
|
||||
def test_download_429_retry(self):
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"abc")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
self.assertEqual(self.jira.download(self._content_path(a), 100), b"abc")
|
||||
self.assertEqual(self.jira.rate_limited, 1)
|
||||
|
||||
def test_download_cut_body_is_connection_error(self): # M-1 финального ревью
|
||||
self.fake.add("AURORA-1")
|
||||
a = self.fake.add_attachment("AURORA-1", "a.bin", b"x" * 40)
|
||||
self.fake.lying_length.add(a["id"])
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.download(self._content_path(a), 1000)
|
||||
|
||||
def test_json_call_redirect_other_host_refused(self): # M-4 финального ревью
|
||||
self.fake.add("AURORA-1")
|
||||
port = self.fake.server.server_port
|
||||
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = "http://localhost:%d/rest/api/2/issue/AURORA-2" % port
|
||||
with self.assertRaises(agent.HttpError) as e:
|
||||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||||
self.assertIn("localhost", e.exception.text)
|
||||
self.assertFalse(any(r[1] == "/rest/api/2/issue/AURORA-2" for r in self.fake.requests))
|
||||
|
||||
def test_json_call_redirect_same_host_followed(self):
|
||||
self.fake.add("AURORA-2")
|
||||
self.fake.redirects["/rest/api/2/issue/AURORA-1"] = self.fake.base_url + "/rest/api/2/issue/AURORA-2"
|
||||
self.assertEqual(self.jira.get("/rest/api/2/issue/AURORA-1")["key"], "AURORA-2")
|
||||
|
||||
def test_retry_on_429_with_retry_after(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||||
@ -84,7 +217,7 @@ class ClientsTest(unittest.TestCase):
|
||||
raise TimeoutError("timed out")
|
||||
|
||||
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
|
||||
with mock.patch("urllib.request.urlopen", side_effect=err):
|
||||
with mock.patch("urllib.request.OpenerDirector.open", side_effect=err):
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.get("/x")
|
||||
self.assertEqual(self.jira.calls, 1)
|
||||
@ -92,7 +225,7 @@ class ClientsTest(unittest.TestCase):
|
||||
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 mock.patch("urllib.request.OpenerDirector.open", side_effect=http.client.IncompleteRead(b"ab", 10)):
|
||||
with self.assertRaises(ConnectionError):
|
||||
self.jira.get("/x")
|
||||
|
||||
|
||||
253
tests/test_executor_attachments.py
Normal file
253
tests/test_executor_attachments.py
Normal file
@ -0,0 +1,253 @@
|
||||
import hashlib
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_hub import FakeHub
|
||||
from fake_jira import FakeJira
|
||||
|
||||
SHA_ABC = hashlib.sha256(b"abc").hexdigest()
|
||||
|
||||
|
||||
class EnsureAttachmentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.jira = agent.JiraClient(cfg)
|
||||
self.jira.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||||
self.ex.sleep = no_sleep
|
||||
self.fake.add("AURORA-1")
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
|
||||
def run_cmd(self, **params):
|
||||
return self.ex.run({"id": 1, "type": "ensure_attachment", "params": params})
|
||||
|
||||
def test_upload_cyrillic_name(self):
|
||||
data = bytes(range(256)) * 8
|
||||
sha = self.hub.put_file(data)
|
||||
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||||
out = self.run_cmd(key="AURORA-1", file=sha, filename=name, size=len(data))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
a = self.fake.attachments["AURORA-1"][0]
|
||||
self.assertEqual(out["result"], {"attachment_id": a["id"], "created": True})
|
||||
self.assertEqual((a["filename"], a["size"], a["data"]), (name, len(data), data))
|
||||
self.assertEqual(self.fake.requests, [("GET", "/rest/api/2/issue/AURORA-1"),
|
||||
("POST", "/rest/api/2/issue/AURORA-1/attachments")])
|
||||
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
|
||||
self.assertEqual(self.hub.file_requests, 1)
|
||||
self.assertEqual(out["jira_calls"], 2)
|
||||
|
||||
def test_existing_same_name_and_size(self):
|
||||
a = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", b"xyz") # другое содержимое, тот же размер
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="ОСВ 60.xlsx", size=3)
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
|
||||
self.assertEqual(out["jira_calls"], 1)
|
||||
self.assertEqual(self.hub.file_requests, 0) # файл у хаба не запрашивается
|
||||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||||
|
||||
def test_same_name_other_size_uploads_second(self):
|
||||
old = self.fake.add_attachment("AURORA-1", "ОСВ 60.xlsx", "старое содержимое".encode("utf-8"))
|
||||
sha = self.hub.put_file(b"abc")
|
||||
out = self.run_cmd(key="AURORA-1", file=sha, filename="ОСВ 60.xlsx", size=3)
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(out["result"]["created"])
|
||||
self.assertNotEqual(out["result"]["attachment_id"], old["id"])
|
||||
self.assertEqual([x["filename"] for x in self.fake.attachments["AURORA-1"]], ["ОСВ 60.xlsx", "ОСВ 60.xlsx"])
|
||||
|
||||
def test_existing_under_wire_name(self):
|
||||
name = 'итог "кв.2".txt'
|
||||
a = self.fake.add_attachment("AURORA-1", agent.wire_filename(name), b"abc") # так Jira сохранила прошлую загрузку
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename=name, size=3)
|
||||
self.assertEqual(out["result"], {"attachment_id": a["id"], "existed": True})
|
||||
self.assertEqual(self.hub.file_requests, 0)
|
||||
|
||||
def test_size_null_fails_without_calls(self):
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=None)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
|
||||
self.assertNotIn("retryable", out)
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
self.assertEqual(self.hub.file_requests, 0)
|
||||
|
||||
def test_hub_404_file_gone(self):
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3) # в хранилище хаба файла нет
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "файл удалён с хаба, загрузите заново")
|
||||
self.assertNotIn("retryable", out)
|
||||
self.assertEqual(self.hub.file_requests, 1)
|
||||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||||
|
||||
def test_corrupted_sha_is_retryable_hub_failure(self):
|
||||
self.hub.files[SHA_ABC] = b"xyz" # размер тот же, содержимое другое
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(out["peer"], "хабом")
|
||||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||||
|
||||
def test_length_mismatch_is_retryable_hub_failure(self):
|
||||
self.hub.put_file(b"abc")
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=4)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(out["peer"], "хабом")
|
||||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||||
|
||||
def test_hub_non_json_file_no_content_in_error(self):
|
||||
def broken(sha):
|
||||
raise agent.HttpError(200, 'ответ не JSON: Unterminated string; начало: {"file": "x", "content": "U0VDUkVULUNPTlRFTlQ')
|
||||
|
||||
self.ex.hub.file = broken
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(out["peer"], "хабом")
|
||||
self.assertNotIn("U0VDUkVU", out["error"])
|
||||
self.assertNotIn("начало", out["error"])
|
||||
|
||||
def test_hub_4xx_other_than_404_not_retryable(self):
|
||||
self.ex.hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": "wrong-token", "timeout": 5})
|
||||
out = self.run_cmd(key="AURORA-1", file=SHA_ABC, filename="a.txt", size=3)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["error"].startswith("хаб: HTTP 401"), out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
|
||||
def test_jira_413_failed_with_code_and_text(self):
|
||||
self.fake.attachment_limit = 2
|
||||
sha = self.hub.put_file(b"abc")
|
||||
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["error"].startswith("HTTP 413"), out["error"])
|
||||
self.assertIn("Размер вложения превышает предел", out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
|
||||
def test_upload_response_without_id(self):
|
||||
self.fake.bad_attachment_response = True
|
||||
sha = self.hub.put_file(b"abc")
|
||||
out = self.run_cmd(key="AURORA-1", file=sha, filename="a.txt", size=3)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "Jira не вернула id вложения в ответе на загрузку")
|
||||
self.assertNotIn("retryable", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class FetchAttachmentTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.jira = agent.JiraClient(cfg)
|
||||
self.jira.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||||
self.ex.sleep = no_sleep
|
||||
self.fake.add("AURORA-1650")
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
|
||||
def fetch(self, a, key="AURORA-1650"):
|
||||
return self.ex.run({"id": 7, "type": "fetch_attachment", "params": {"key": key, "attachment_id": a["id"]}})
|
||||
|
||||
def test_fetch_screenshot(self): # сценарий «Скачивание скриншота»
|
||||
data = bytes(range(256)) * 900
|
||||
a = self.fake.add_attachment("AURORA-1650", "скрин 1.png", data, mime="image/png")
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"], {"attachment_id": a["id"], "file": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data)})
|
||||
self.assertEqual(self.hub.uploaded, [data])
|
||||
self.assertEqual(self.fake.requests[0], ("GET", "/rest/api/2/issue/AURORA-1650"))
|
||||
self.assertEqual(self.fake.queries[0].get("fields"), "attachment")
|
||||
self.assertEqual(self.fake.requests[1][1],
|
||||
"/secure/attachment/%s/%%D1%%81%%D0%%BA%%D1%%80%%D0%%B8%%D0%%BD%%201.png" % a["id"])
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_fetch_deleted(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.fake.attachments["AURORA-1650"] = []
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "вложение #%s удалено из AURORA-1650 в Jira" % a["id"]))
|
||||
self.assertEqual(self.fake.content_gets, 0)
|
||||
|
||||
def test_fetch_too_large_known(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "запись.mp4", b"x")
|
||||
a["size"] = 25000000
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "файл больше 10 МБ (23,8 МБ) — скачайте в Jira"))
|
||||
self.assertEqual(self.fake.content_gets, 0)
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_too_large_unknown(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "big.bin", b"x" * (12 * 1024 * 1024))
|
||||
self.fake.hide_size.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "файл больше 10 МБ (больше 10,0 МБ) — скачайте в Jira"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_unknown_size_ok(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.json", b'{"a": 1}', mime="application/json")
|
||||
self.fake.hide_size.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["size"], 8)
|
||||
|
||||
def test_fetch_redirect_away(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.fake.redirect_content = "http://localhost:%d/secure/moved/%s/a.png" % (self.fake.server.server_port, a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "Jira перенаправила скачивание на localhost"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_truncated(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.bin", b"abcdef")
|
||||
self.fake.short_content.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "файл пришёл не целиком (5 из 6)"))
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
|
||||
def test_fetch_empty(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "empty.txt", b"")
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["size"], 0)
|
||||
self.assertEqual(self.hub.uploaded, [b""])
|
||||
|
||||
def test_fetch_hub_down(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.hub.reject_files = "drop"
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(out["peer"], "хабом")
|
||||
self.assertTrue(out["error"].startswith("хаб: "))
|
||||
|
||||
def test_fetch_hub_422(self):
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.png", b"abc")
|
||||
self.hub.reject_files = (422, {"error": "bad_request", "detail": "content — не base64"})
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertTrue(out["error"].startswith("хаб: "), out["error"])
|
||||
|
||||
def test_fetch_unknown_size_cut_body_retryable(self): # M-1 финального ревью
|
||||
a = self.fake.add_attachment("AURORA-1650", "a.json", b"x" * 40)
|
||||
self.fake.hide_size.add(a["id"])
|
||||
self.fake.lying_length.add(a["id"])
|
||||
out = self.fetch(a)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertEqual(self.hub.uploaded, [])
|
||||
@ -48,6 +48,34 @@ class ExecutorFieldsTest(unittest.TestCase):
|
||||
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_issue_bug_by_name(self):
|
||||
self.assertEqual(self.ex.issue_type_ref("bug"), {"name": "Ошибка"}) # id нет — по имени
|
||||
self.assertEqual(self.ex.issue_type_ref("task"), {"id": "10100"}) # id есть — по id
|
||||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="ОСВ 60 не распознана",
|
||||
labels=["AWPCA"])
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(out["result"]["created"])
|
||||
self.assertEqual(self.fake.issues[out["result"]["key"]]["issuetype"], "Ошибка")
|
||||
|
||||
def test_ensure_issue_type_not_configured_no_jira_calls(self):
|
||||
self.ex.s = dict(SETTINGS, issue_types={"task": {"name": "Задача", "id": "10100"}})
|
||||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="bug", summary="x")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "нет настройки issue_types.bug")
|
||||
self.assertNotIn("retryable", out)
|
||||
self.assertEqual(self.fake.requests, []) # ни поиска дубля, ни создания
|
||||
|
||||
def test_issue_type_entry_without_name_and_id(self):
|
||||
for entry in ({}, {"name": None, "id": None}, {"name": "", "id": ""}, None, "Ошибка"):
|
||||
self.ex.s = dict(SETTINGS, issue_types={"bug": entry})
|
||||
with self.assertRaises(agent.ConfigGap) as cm:
|
||||
self.ex.issue_type_ref("bug")
|
||||
self.assertEqual(str(cm.exception), "нет настройки issue_types.bug")
|
||||
self.ex.s = dict(SETTINGS)
|
||||
del self.ex.s["issue_types"]
|
||||
with self.assertRaises(agent.ConfigGap):
|
||||
self.ex.issue_type_ref("task")
|
||||
|
||||
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="Средний",
|
||||
@ -231,6 +259,119 @@ class ExecutorFieldsTest(unittest.TestCase):
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3")
|
||||
self.assertEqual(out["result"], {"changed": []})
|
||||
|
||||
# --- story_points (Task 4, #49) ----------------------------------------------
|
||||
def test_story_points_set_clear_idempotent(self):
|
||||
self.fake.add("AURORA-1")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
|
||||
self.assertEqual(out["result"], {"changed": ["story_points"]})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3)
|
||||
for v in (3, 3.0):
|
||||
again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
|
||||
self.assertEqual(again["result"], {"changed": []})
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", story_points=None)
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_10106"])
|
||||
|
||||
def test_story_points_no_field(self): # сценарий «Нет поля оценки»
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fields = []
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "поле оценки не найдено в Jira: задайте field_ids.story_points"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_story_points_override(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", story_points=5)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_20000"], 5)
|
||||
self.assertNotIn(("GET", "/rest/api/2/field"), self.fake.requests)
|
||||
|
||||
def test_story_points_bad_value(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for v in ("3", -1, True):
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"), v)
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
|
||||
# --- story_points через доску (#53) ------------------------------------------
|
||||
def points(self, key, value):
|
||||
self.fake.add(key, **({} if value is None else {"customfield_10106": value}))
|
||||
|
||||
def test_points_by_board_set(self): # сценарий «Оценка не на экране редактирования»
|
||||
self.points("AURORA-1704", 1.0)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216)
|
||||
self.assertEqual((out["status"], out["result"], out["jira_calls"]), ("done", {"changed": ["story_points"]}, 2))
|
||||
self.assertEqual(self.fake.estimation_bodies, [{"value": 2}])
|
||||
self.assertEqual(self.fake.issues["AURORA-1704"]["customfield_10106"], 2)
|
||||
self.assertEqual(self.fake.queries[-1], {"boardId": "4216"})
|
||||
for call in (("GET", "/rest/api/2/issue/AURORA-1704"), ("PUT", "/rest/api/2/issue/AURORA-1704"),
|
||||
("GET", "/rest/api/2/field")):
|
||||
self.assertNotIn(call, self.fake.requests)
|
||||
|
||||
def test_points_by_board_same(self): # сценарий «Оценка уже стоит»
|
||||
self.points("AURORA-1704", 2.0)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216)
|
||||
self.assertEqual((out["result"], out["jira_calls"]), ({"changed": []}, 1))
|
||||
self.assertEqual(self.fake.estimation_bodies, [])
|
||||
|
||||
def test_points_by_board_clear(self):
|
||||
self.points("AURORA-1", 1.0)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216)
|
||||
self.assertEqual(out["result"], {"changed": ["story_points"]})
|
||||
self.assertEqual(self.fake.estimation_bodies, [{"value": None}])
|
||||
again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216)
|
||||
self.assertEqual((again["result"], again["jira_calls"]), ({"changed": []}, 1))
|
||||
|
||||
def test_points_by_board_with_fields(self):
|
||||
self.points("AURORA-1", None)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216)
|
||||
self.assertEqual(out["result"], {"changed": ["duedate", "story_points"]})
|
||||
writes = [r for r in self.fake.requests if r[0] == "PUT"]
|
||||
self.assertEqual(writes, [("PUT", "/rest/api/2/issue/AURORA-1"), ("PUT", "/rest/agile/1.0/issue/AURORA-1/estimation")])
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30")
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3)
|
||||
|
||||
def test_points_by_board_jira_error(self):
|
||||
self.points("AURORA-1", None)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=9999)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("9999", out["error"])
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30")
|
||||
|
||||
def test_points_by_board_no_field_lookup(self):
|
||||
self.points("AURORA-1", None)
|
||||
self.fake.fields = []
|
||||
self.ex.s["field_ids"] = {k: v for k, v in SETTINGS["field_ids"].items() if k != "story_points"}
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3, board_id=4216)
|
||||
self.assertEqual((out["status"], out["result"]), ("done", {"changed": ["story_points"]}))
|
||||
|
||||
def test_points_by_board_bad_value(self):
|
||||
self.points("AURORA-1", None)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points="3", board_id=4216)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"))
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
|
||||
def test_points_by_board_gap_after_fields(self): # I-1: зазор ensure_fields между двумя записями
|
||||
gaps = []
|
||||
self.ex.sleep = gaps.append
|
||||
self.ex.s["pacing"] = {"ensure_fields": 120}
|
||||
self.points("AURORA-1", None)
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216)
|
||||
self.assertEqual(gaps, [120])
|
||||
self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=5, board_id=4216)
|
||||
self.assertEqual(gaps, [120]) # PUT /issue не было — паузы нет
|
||||
|
||||
def test_points_by_board_other_field(self): # I-2: оценка доски — не поле story points
|
||||
self.points("AURORA-1", 1.0)
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216)
|
||||
self.assertEqual((out["status"], out["error"]),
|
||||
("failed", "у доски 4216 оценка — поле customfield_10106, а не story points (customfield_20000)"))
|
||||
self.assertEqual(self.fake.estimation_bodies, [])
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_10106")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216)
|
||||
self.assertEqual(out["result"], {"changed": ["story_points"]})
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -199,6 +199,49 @@ class ExecutorLinksTest(unittest.TestCase):
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("board_id", out["error"])
|
||||
|
||||
def test_sprint_board_from_params(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
|
||||
paths = [r[1] for r in self.fake.requests]
|
||||
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
|
||||
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
|
||||
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
|
||||
|
||||
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
|
||||
self.ex.s["board_id"] = None
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||||
|
||||
def test_sprint_unknown_name_names_param_board(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("5120", out["error"])
|
||||
self.assertIn("Спринт K", out["error"])
|
||||
|
||||
def test_sprint_kanban_board_failed_with_jira_text(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 400", out["error"])
|
||||
self.assertIn("does not support sprints", out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
|
||||
|
||||
def test_sprint_backlog_ignores_board(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
|
||||
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
|
||||
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
267
tests/test_executor_sprints.py
Normal file
267
tests/test_executor_sprints.py
Normal file
@ -0,0 +1,267 @@
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
|
||||
B = {"board_id": 4216}
|
||||
|
||||
|
||||
class ExecutorSprintsTest(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")
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def run_cmd(self, ctype, **params):
|
||||
return self.ex.run({"id": 1, "type": ctype, "params": params})
|
||||
|
||||
# --- ensure_sprint_meta ---
|
||||
def test_meta_create_then_repeat(self): # сценарий «Создание спринта повтором не дублируется»
|
||||
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}]
|
||||
params = dict(B, sprint="AWPCA_Sprint_4", start_date="2026-10-28", end_date="2026-11-11", goal="свод ВГО")
|
||||
out = self.run_cmd("ensure_sprint_meta", **params)
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
sid = out["result"]["sprint_id"]
|
||||
self.assertEqual(out["result"], {"sprint_id": sid, "created": True})
|
||||
sp = self.fake.board_sprints[4216][-1]
|
||||
off = datetime.now().astimezone().strftime("%z")
|
||||
self.assertEqual((sp["id"], sp["name"], sp["state"], sp["originBoardId"], sp["goal"]),
|
||||
(sid, "AWPCA_Sprint_4", "future", 4216, "свод ВГО"))
|
||||
self.assertEqual((sp["startDate"], sp["endDate"]),
|
||||
("2026-10-28T09:00:00.000" + off, "2026-11-11T18:00:00.000" + off))
|
||||
again = self.run_cmd("ensure_sprint_meta", **params)
|
||||
self.assertEqual(again["result"], {"sprint_id": sid, "created": False})
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
|
||||
def test_meta_updates_only_diff(self): # даты совпадают по дню, отличается цель
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future", "goal": "старая",
|
||||
"startDate": "2026-10-28T10:00:00.000+03:00",
|
||||
"endDate": "2026-11-11T19:00:00.000+03:00"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", start_date="2026-10-28", end_date="2026-11-11",
|
||||
goal="новая", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"goal": "новая"})])
|
||||
|
||||
def test_meta_rename_and_name_taken(self): # S → T: POST {"name": "T"}; есть S и T — «имя занято», без записи
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"name": "T"})])
|
||||
self.fake.sprint_posts, self.fake.requests = [], []
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future"},
|
||||
{"id": 10, "name": "T", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertEqual(out["error"], "имя занято")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_repeat_after_rename(self): # Review Focus 3, R-69
|
||||
self.fake.board_sprints[4216] = [{"id": 9, "name": "T", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_closed_and_duplicates(self):
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "C", "state": "closed"},
|
||||
{"id": 5, "name": "D", "state": "future"},
|
||||
{"id": 6, "name": "D", "state": "active"}]
|
||||
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="C", goal="x", **B)["error"], "спринт закрыт")
|
||||
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="D", **B)["error"],
|
||||
"несколько спринтов с именем «D»: 5, 6")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_bad_date(self): # Review Focus 5
|
||||
for bad in ("28.10.2026", "2026-13-01"):
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="N", start_date=bad, **B)
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("ГГГГ-ММ-ДД", out["error"])
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_without_board(self): # R-70
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="N")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны"))
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
|
||||
# --- ensure_sprint_state ---
|
||||
def sprint_with(self, n_done, n_open): # спринт 3 активный, спринт 4 будущий, задачи по имени спринта
|
||||
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"},
|
||||
{"id": 4, "name": "AWPCA_Sprint_4", "state": "future"}]
|
||||
open_keys = []
|
||||
for i in range(n_done + n_open):
|
||||
key = "AURORA-%d" % (2000 + i)
|
||||
self.fake.add(key, sprint="AWPCA_Sprint_3_Corrections", status="Готово" if i < n_done else "Разработка")
|
||||
if i >= n_done:
|
||||
open_keys.append(key)
|
||||
return open_keys
|
||||
|
||||
def test_close_with_move(self): # сценарий «Завершение спринта с переносом»
|
||||
open_keys = self.sprint_with(25, 12)
|
||||
params = dict(B, sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="AWPCA_Sprint_4")
|
||||
out = self.run_cmd("ensure_sprint_state", **params)
|
||||
self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys})
|
||||
self.assertEqual({self.fake.issues[k]["sprint"] for k in open_keys}, {"AWPCA_Sprint_4"})
|
||||
self.assertEqual(self.fake.board_sprints[4216][0]["state"], "closed")
|
||||
writes = len(self.fake.writes())
|
||||
again = self.run_cmd("ensure_sprint_state", **params)
|
||||
self.assertEqual(again["result"], {"sprint_id": 3, "moved": []})
|
||||
self.assertEqual(len(self.fake.writes()), writes)
|
||||
|
||||
def test_close_chunks_and_gap(self):
|
||||
self.sprint_with(0, 60)
|
||||
self.ex.s["pacing"] = {"ensure_sprint_state": 30}
|
||||
self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual([p for _, p in self.fake.writes()],
|
||||
["/rest/agile/1.0/backlog/issue"] * 2 + ["/rest/agile/1.0/sprint/3"])
|
||||
self.assertEqual(self.gaps, [30, 30])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/agile/1.0/sprint/3/issue")), 2)
|
||||
|
||||
def test_activate_future(self):
|
||||
self.fake.board_sprints[4216] = [{"id": 4, "name": "S4", "state": "future",
|
||||
"startDate": "2026-10-28T09:00:00.000+03:00",
|
||||
"endDate": "2026-11-11T18:00:00.000+03:00"}]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 4, "moved": []})
|
||||
self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/4", {
|
||||
"state": "active", "startDate": "2026-10-28T09:00:00.000+03:00", "endDate": "2026-11-11T18:00:00.000+03:00"})])
|
||||
self.assertEqual(self.fake.board_sprints[4216][0]["state"], "active")
|
||||
writes = len(self.fake.writes())
|
||||
again = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B)
|
||||
self.assertEqual(again["result"], {"sprint_id": 4, "moved": []})
|
||||
self.assertEqual(len(self.fake.writes()), writes)
|
||||
self.fake.board_sprints[4216] = [{"id": 5, "name": "S5", "state": "future"}]
|
||||
self.fake.sprint_posts, self.fake.requests = [], []
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="S5", state="active", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "у спринта нет дат: задайте ensure_sprint_meta"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_state_wrong_transitions(self):
|
||||
self.fake.board_sprints[4216] = [{"id": 4, "name": "F", "state": "future"},
|
||||
{"id": 1, "name": "C", "state": "closed"}]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="F", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "спринт не начат"))
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="C", state="active", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "спринт закрыт"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_state_validation(self):
|
||||
self.sprint_with(0, 1)
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="future", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "state — active или closed"))
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "move_open_to обязателен при state = closed"))
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="Нет", state="active", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "нет спринта «Нет» на доске 4216"))
|
||||
self.fake.board_sprints[4216] += [{"id": 7, "name": "Д", "state": "future"}, {"id": 8, "name": "Д", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="Д", state="active", **B)
|
||||
self.assertEqual(out["error"], "несколько спринтов с именем «Д»: 7, 8")
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_state_bad_move_target_no_writes(self): # Review Focus 4
|
||||
self.sprint_with(1, 1)
|
||||
self.fake.board_sprints[4216].append({"id": 1, "name": "Старый", "state": "closed"})
|
||||
for target in ("Нет такого", "Старый", "AWPCA_Sprint_3_Corrections"):
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to=target, **B)
|
||||
self.assertEqual(out["status"], "failed", target)
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_close_columns_unreadable(self):
|
||||
self.sprint_with(1, 1)
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_close_last_column_unknown_status_no_writes(self): # C-1: пустой набор id последней колонки
|
||||
self.sprint_with(2, 1)
|
||||
self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", [])]
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_close_status_added_after_cache(self): # C-1: статус добавлен в последнюю колонку после заполнения кэша
|
||||
open_keys = self.sprint_with(0, 1)
|
||||
self.ex.status_names() # кэш имён заполнен до появления нового статуса
|
||||
self.fake.statuses["10010"] = "Принято"
|
||||
self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", ["10006", "10010"])]
|
||||
self.fake.add("AURORA-3000", sprint="AWPCA_Sprint_3_Corrections", status="Принято")
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys})
|
||||
self.assertEqual(self.fake.issues["AURORA-3000"]["sprint"], "AWPCA_Sprint_3_Corrections")
|
||||
|
||||
def test_close_issue_without_status_id_is_open(self): # R-81: нет status.id — незавершённая
|
||||
self.sprint_with(1, 0)
|
||||
self.fake.add("AURORA-3001", sprint="AWPCA_Sprint_3_Corrections", status="Без id в statuses")
|
||||
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 3, "moved": ["AURORA-3001"]})
|
||||
|
||||
def test_meta_rename_to_closed_name_taken(self): # M-2
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "имя занято"))
|
||||
self.fake.board_sprints[4216].append({"id": 9, "name": "S", "state": "future"})
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "имя занято"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_meta_repeat_after_rename_with_closed_namesake(self): # M-2 не ломает R-69
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}, {"id": 9, "name": "T", "state": "future"}]
|
||||
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
|
||||
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
# --- ensure_rank ---
|
||||
def test_rank_already(self): # сценарий «Ранг уже такой»
|
||||
self.fake.add("AURORA-1612", customfield_10105="0|i0001:")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
|
||||
self.assertEqual((out["status"], out["result"]), ("done", {"changed": False}))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_rank_before_and_after_write(self):
|
||||
self.fake.add("AURORA-1612", customfield_10105="0|i0003:")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
|
||||
self.assertEqual(out["result"], {"changed": True})
|
||||
self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105})
|
||||
self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")["result"], {"changed": False})
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", after="AURORA-1601")["result"], {"changed": True})
|
||||
self.assertGreater(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
|
||||
|
||||
def test_rank_null_rank_writes(self): # у задачи нет ранга (None) — сравнивать нечего, пишем
|
||||
self.fake.add("AURORA-1612")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
|
||||
self.assertEqual((out["status"], out["result"]), ("done", {"changed": True}))
|
||||
self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105})
|
||||
self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
|
||||
|
||||
def test_rank_bad_params(self): # до любых запросов к Jira — ни поиска, ни записи
|
||||
self.fake.add("AURORA-1612", customfield_10105="0|i0003:")
|
||||
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601", after="AURORA-1601")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after"))
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after"))
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1612")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "задача не может стоять выше или ниже самой себя"))
|
||||
self.assertEqual(self.fake.writes(), [])
|
||||
|
||||
def test_rank_no_field(self):
|
||||
self.fake.add("AURORA-1"); self.fake.add("AURORA-2")
|
||||
self.fake.fields = []
|
||||
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1", after="AURORA-2")["error"], "поле ранга не найдено")
|
||||
|
||||
def test_rank_207_error(self): # Review Focus 1
|
||||
self.fake.add("AURORA-1", customfield_10105="0|i0009:"); self.fake.add("AURORA-2", customfield_10105="0|i0001:")
|
||||
self.fake.rank_errors["AURORA-1"] = "Ранжирование недоступно"
|
||||
out = self.run_cmd("ensure_rank", key="AURORA-1", before="AURORA-2")
|
||||
self.assertEqual((out["status"], out["error"]), ("failed", "Jira не переранжировала AURORA-1: Ранжирование недоступно"))
|
||||
@ -1,3 +1,5 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@ -137,6 +139,43 @@ class FolderTransportTest(unittest.TestCase):
|
||||
self.assertIsNone(hub.folder)
|
||||
self.assertEqual(hub.settings()["project_key"], "AURORA")
|
||||
|
||||
def test_file_through_folder(self):
|
||||
self.run_relay()
|
||||
data = os.urandom(300 * 1024)
|
||||
sha = self.hub.put_file(data)
|
||||
got = self.client().file(sha)
|
||||
self.assertEqual((got["file"], got["size"]), (sha, len(data)))
|
||||
self.assertEqual(base64.b64decode(got["content"]), data)
|
||||
self.assertEqual(self.relay.requests[0]["path"], "/agent/files/" + sha)
|
||||
self.assertEqual(self.relay.requests[0]["method"], "GET")
|
||||
|
||||
def test_upload_file_through_folder(self):
|
||||
self.run_relay()
|
||||
data = os.urandom(300 * 1024)
|
||||
got = self.client().upload_file(data)
|
||||
self.assertEqual(got, {"file": hashlib.sha256(data).hexdigest(), "size": len(data)})
|
||||
self.assertEqual(self.hub.uploaded, [data])
|
||||
self.assertEqual(self.relay.requests[0]["path"], "/agent/files")
|
||||
|
||||
def test_upload_file_over_http(self):
|
||||
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||
self.assertEqual(hub.upload_file(b""), {"file": hashlib.sha256(b"").hexdigest(), "size": 0})
|
||||
self.assertEqual(self.hub.uploaded, [b""])
|
||||
|
||||
def test_file_missing_through_folder(self):
|
||||
self.run_relay()
|
||||
with self.assertRaises(agent.HttpError) as e:
|
||||
self.client().file("0" * 64)
|
||||
self.assertEqual(e.exception.code, 404)
|
||||
self.assertIn("no_file", e.exception.text)
|
||||
|
||||
def test_file_over_http(self):
|
||||
sha = self.hub.put_file(b"abc")
|
||||
hub = agent.HubClient({"hub_url": self.hub.base_url, "hub_token": self.hub.token, "timeout": 5})
|
||||
got = hub.file(sha)
|
||||
self.assertEqual(base64.b64decode(got["content"]), b"abc")
|
||||
self.assertEqual(self.hub.file_requests, 1)
|
||||
|
||||
|
||||
|
||||
class FolderIntegrationTest(unittest.TestCase):
|
||||
@ -157,8 +196,8 @@ class FolderIntegrationTest(unittest.TestCase):
|
||||
def cfg(self):
|
||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||
|
||||
def test_version_is_9(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 9)
|
||||
def test_version(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 16)
|
||||
|
||||
def test_runner_through_folder(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||
|
||||
317
tests/test_history.py
Normal file
317
tests/test_history.py
Normal file
@ -0,0 +1,317 @@
|
||||
"""Журнал изменений Jira в refresh (агент v13, spec agent «Журнал изменений»)."""
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_hub import FakeHub
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
def _st(field, frm, to, fieldtype="jira", **kw):
|
||||
return dict({"field": field, "fieldtype": fieldtype, "from": frm, "fromString": frm, "to": to, "toString": to}, **kw)
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
self.hub = FakeHub(SETTINGS)
|
||||
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
|
||||
self.jira = agent.JiraClient(cfg)
|
||||
self.jira.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
|
||||
self.ex.sleep = no_sleep
|
||||
self.logs = []
|
||||
self._log = agent.log
|
||||
agent.log = self.logs.append
|
||||
|
||||
def tearDown(self):
|
||||
agent.log = self._log
|
||||
self.fake.stop()
|
||||
self.hub.stop()
|
||||
|
||||
|
||||
class ChangelogItemsTest(Base):
|
||||
def test_jira_time_forms(self):
|
||||
want = datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc)
|
||||
for raw in ("2026-10-22T13:05:11.000+0300", "2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11Z",
|
||||
"2026-10-22T10:05:11.000Z", "2026-10-22T05:05:11.000-0500"):
|
||||
self.assertEqual(agent._jira_time(raw), want, raw)
|
||||
for raw in ("мусор", "", None, 42):
|
||||
self.assertIsNone(agent._jira_time(raw))
|
||||
|
||||
def test_changelog_items_since_and_shape(self):
|
||||
histories = [
|
||||
{"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300",
|
||||
"items": [_st("status", "Открыта", "В работе")]},
|
||||
{"id": 2, "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300",
|
||||
"items": [dict(_st("status", "В работе", "Готово"), fieldId="status", **{"from": "3", "to": "10001"}),
|
||||
_st("resolution", None, "Готово")]}]
|
||||
items = self.ex.changelog_items("X", histories, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
|
||||
self.assertEqual([(i["history_id"], i["seq"]) for i in items], [("2", 0), ("2", 1)])
|
||||
self.assertEqual(items[0], {"history_id": "2", "seq": 0, "key": "X", "created": "2026-10-22T12:10:00.000+0300",
|
||||
"author": "ivanov", "field_id": "status", "field_name": "status", "from": "3",
|
||||
"from_string": "В работе", "to": "10001", "to_string": "Готово"})
|
||||
self.assertEqual(items[1]["field_id"], "resolution")
|
||||
self.assertIsNone(items[1]["from"])
|
||||
|
||||
def test_changelog_items_since_boundary_included(self):
|
||||
h = [{"id": "1", "author": None, "created": "2026-10-22T11:45:00.000+0300", "items": [_st("status", "a", "b")]}]
|
||||
items = self.ex.changelog_items("X", h, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
|
||||
self.assertEqual(len(items), 1)
|
||||
self.assertIsNone(items[0]["author"])
|
||||
|
||||
def test_changelog_items_custom_field_lookup(self):
|
||||
asked = []
|
||||
|
||||
def field_id_of(name):
|
||||
asked.append(name)
|
||||
return "customfield_10201"
|
||||
h = [{"id": "1", "author": {"name": "p"}, "created": "2026-10-22T12:00:00.000+0300",
|
||||
"items": [_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom"),
|
||||
_st("Sprint", "1", "2", fieldtype="custom", fieldId="customfield_10104"),
|
||||
_st("duedate", "2026-10-25", "2026-10-30")]}]
|
||||
items = self.ex.changelog_items("X", h, None, field_id_of)
|
||||
self.assertEqual([i["field_id"] for i in items], ["customfield_10201", "customfield_10104", "duedate"])
|
||||
self.assertEqual(asked, ["Плановое окончание"])
|
||||
|
||||
def test_changelog_items_drops_bad(self):
|
||||
h = [{"id": None, "author": None, "created": "2026-10-22T12:00:00.000+0300", "items": [_st("status", "a", "b")]},
|
||||
{"id": "2", "author": None, "created": "", "items": [_st("status", "a", "b")]},
|
||||
{"id": "3", "author": None, "created": "2026-10-22T12:00:00.000+0300",
|
||||
"items": [_st("", "a", "b"), _st("status", 1, 2)]}]
|
||||
items = self.ex.changelog_items("X", h, None, lambda n: None)
|
||||
self.assertEqual([(i["history_id"], i["seq"], i["from"], i["to"]) for i in items], [("3", 1, "1", "2")])
|
||||
self.assertEqual(len([l for l in self.logs if "X" in l]), 3)
|
||||
|
||||
def test_changelog_items_no_since(self):
|
||||
h = [{"id": str(n), "author": None, "created": "2020-01-01T00:00:00.000+0300", "items": [_st("status", "a", "b")]}
|
||||
for n in range(3)]
|
||||
self.assertEqual(len(self.ex.changelog_items("X", h, None, lambda n: None)), 3)
|
||||
|
||||
|
||||
class FakeChangelogTest(Base):
|
||||
def test_fake_search_expands_changelog(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for n in range(3):
|
||||
self.fake.add_history("AURORA-1", "2026-10-22T12:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.changelog_cap = 2
|
||||
page = self.jira.get(agent.API + "/search", jql="key in (AURORA-1)", fields="key", expand="changelog")
|
||||
cl = page["issues"][0]["changelog"]
|
||||
self.assertEqual((cl["total"], len(cl["histories"])), (3, 2))
|
||||
self.fake.issue_changelog_cap = 3
|
||||
full = self.jira.get(agent.API + "/issue/AURORA-1", fields="key", expand="changelog")
|
||||
self.assertEqual(len(full["changelog"]["histories"]), 3)
|
||||
self.assertNotIn("changelog", self.jira.get(agent.API + "/issue/AURORA-1"))
|
||||
self.fake.jql_results["x AND sprint in openSprints()"] = ["AURORA-1"]
|
||||
self.assertEqual(self.fake.search_keys("x AND sprint in openSprints()"), ["AURORA-1"])
|
||||
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
|
||||
|
||||
|
||||
SINCE = "2026-10-22T08:45:00Z"
|
||||
CHANGED = "2026-10-22T12:30:00.000+0300" # 09:30 UTC — позже SINCE
|
||||
BASE = "project = AURORA AND labels = AWPCA"
|
||||
|
||||
|
||||
class RefreshHistoryTest(Base):
|
||||
def refresh(self, history=None, **params):
|
||||
p = dict({"jql": BASE}, **params)
|
||||
if history is not None:
|
||||
p["history"] = history
|
||||
return self.ex.run({"id": 5, "type": "refresh", "params": p})
|
||||
|
||||
def changelog_queries(self):
|
||||
return [q for q in self.fake.queries if q.get("expand") == "changelog"]
|
||||
|
||||
def sent(self):
|
||||
return [c for b in self.hub.histories for c in b["changes"]]
|
||||
|
||||
def test_nothing_changed_no_changelog_calls(self): # «Ничего не менялось»
|
||||
for n in range(3):
|
||||
self.fake.add("AURORA-%d" % n)
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"]["history"], {"issues": 0, "changes": 0, "incomplete": []})
|
||||
self.assertEqual(self.changelog_queries(), [])
|
||||
self.assertEqual(self.hub.histories, [])
|
||||
|
||||
def test_two_changed_one_search(self): # «Две задачи изменились»
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add("Y", updated=CHANGED)
|
||||
self.fake.add("Z")
|
||||
self.fake.add_history("X", "2026-10-21T12:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c"), _st("resolution", None, "Готово")])
|
||||
self.fake.add_history("Y", "2026-10-22T12:30:00.000+0300", [_st("description", "старое", "новое")])
|
||||
out = self.refresh({"since": SINCE})
|
||||
qs = self.changelog_queries()
|
||||
self.assertEqual(len(qs), 1)
|
||||
self.assertEqual(qs[0]["jql"], "key in (X,Y)")
|
||||
self.assertEqual(qs[0]["fields"], "key")
|
||||
self.assertEqual([(c["key"], c["seq"]) for c in self.sent()], [("X", 0), ("X", 1), ("Y", 0)])
|
||||
self.assertEqual(self.hub.histories[0]["command_id"], 5)
|
||||
self.assertEqual(out["result"]["history"], {"issues": 2, "changes": 3, "incomplete": []})
|
||||
self.assertEqual(self.hub.posts, ["/agent/state", "/agent/history"]) # журнал — после состояния
|
||||
|
||||
def test_backfill_by_jql(self): # «Стартовая загрузка»
|
||||
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||
keys = ["AURORA-%d" % n for n in range(12)]
|
||||
for k in keys:
|
||||
self.fake.add(k)
|
||||
self.fake.add_history(k, "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.jql_results[jql] = keys
|
||||
out = self.refresh({"jql": jql})
|
||||
self.assertEqual([q["jql"] for q in self.changelog_queries()], [jql])
|
||||
self.assertEqual(len(self.sent()), 12)
|
||||
self.assertEqual(out["result"]["history"], {"issues": 12, "changes": 12, "incomplete": []})
|
||||
|
||||
def test_since_and_jql_dedup(self): # Review Focus 4
|
||||
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add("Y")
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.add_history("Y", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.jql_results[jql] = ["X", "Y"]
|
||||
out = self.refresh({"since": SINCE, "jql": jql})
|
||||
self.assertEqual(sorted(c["key"] for c in self.sent()), ["X", "Y"])
|
||||
self.assertEqual(out["result"]["history"]["changes"], 2)
|
||||
|
||||
def test_since_and_jql_keeps_old_history(self): # I-1: задача в обеих выборках — журнал целиком
|
||||
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c")])
|
||||
self.fake.jql_results[jql] = ["X"]
|
||||
out = self.refresh({"since": SINCE, "jql": jql})
|
||||
self.assertEqual(sorted(c["created"][:4] for c in self.sent()), ["2020", "2026"])
|
||||
self.assertEqual(out["result"]["history"]["changes"], 2)
|
||||
self.assertEqual(len(self.changelog_queries()), 1) # X уже прочитана стартовой — второго search нет
|
||||
|
||||
def test_backfill_failure_on_issue_leaves_it_to_since(self): # I-1: упавшая на задаче стартовая часть её не «съедает»
|
||||
jql = "(%s) AND sprint in openSprints() AND statusCategory != Done" % BASE
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2020-01-01T00:00:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "b", "c")])
|
||||
self.fake.jql_results[jql] = ["X"]
|
||||
self.fake.changelog_cap = 1 # search обрезает — дочитывание GET /issue отдаёт обе записи
|
||||
self.fake.issue_changelog_cap = 2
|
||||
orig, failed = self.fake.route, []
|
||||
|
||||
def route(method, path, q, body):
|
||||
if path == "/rest/api/2/issue/X" and q.get("expand") == "changelog" and not failed:
|
||||
failed.append(1)
|
||||
return 500, {"errorMessages": ["сбой"]}
|
||||
return orig(method, path, q, body)
|
||||
self.fake.route = route
|
||||
out = self.refresh({"since": SINCE, "jql": jql})
|
||||
self.assertTrue(out["result"]["history"]["backfill_error"].startswith("HTTP 500"))
|
||||
self.assertEqual([c["created"][:4] for c in self.sent()], ["2026"])
|
||||
|
||||
def test_neighbor_changed_included(self): # Review Focus 5
|
||||
self.fake.add("X")
|
||||
self.fake.add("N", in_base=False, updated=CHANGED)
|
||||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "X", "target": "N"})
|
||||
self.fake.add_history("N", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.refresh({"since": SINCE})
|
||||
self.assertEqual([c["key"] for c in self.sent()], ["N"])
|
||||
|
||||
def test_jira_500_degrades(self): # «Ошибка Jira при чтении журнала»
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
orig = self.fake.route
|
||||
|
||||
def route(method, path, q, body):
|
||||
if q.get("expand") == "changelog":
|
||||
return 500, {"errorMessages": ["сбой"]}
|
||||
return orig(method, path, q, body)
|
||||
self.fake.route = route
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(out["result"]["history"]["error"].startswith("HTTP 500"))
|
||||
self.assertNotIn("retryable", out)
|
||||
self.assertTrue(self.hub.states)
|
||||
self.assertTrue(any("журнал" in l for l in self.logs))
|
||||
|
||||
def test_backfill_400(self): # «Ошибка стартовой загрузки»
|
||||
jql = "bad jql"
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
orig = self.fake.route
|
||||
|
||||
def route(method, path, q, body):
|
||||
if q.get("jql") == jql:
|
||||
return 400, {"errorMessages": ["Значение не существует"]}
|
||||
return orig(method, path, q, body)
|
||||
self.fake.route = route
|
||||
out = self.refresh({"since": SINCE, "jql": jql})
|
||||
h = out["result"]["history"]
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(h["backfill_error"].startswith("HTTP 400"))
|
||||
self.assertNotIn("error", h)
|
||||
self.assertEqual([c["key"] for c in self.sent()], ["X"])
|
||||
|
||||
def test_incomplete(self): # «Неполный журнал»
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
for n in range(250):
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.changelog_cap = 100
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["result"]["history"]["incomplete"], ["X"])
|
||||
self.assertEqual(len(self.sent()), 100)
|
||||
self.assertTrue(any("X: получено 100 из 250" in l for l in self.logs))
|
||||
self.assertIn(("GET", "/rest/api/2/issue/X"), self.fake.requests)
|
||||
|
||||
def test_truncated_search_completed_by_issue(self):
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
for n in range(150):
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.fake.changelog_cap = 100
|
||||
self.fake.issue_changelog_cap = 150
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["result"]["history"], {"issues": 1, "changes": 150, "incomplete": []})
|
||||
|
||||
def test_custom_field_lookup_once(self): # «Плановая дата без fieldId»
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add("Y", updated=CHANGED)
|
||||
for k in ("X", "Y"):
|
||||
self.fake.add_history(k, "2026-10-22T12:10:00.000+0300",
|
||||
[_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom")])
|
||||
self.refresh({"since": SINCE})
|
||||
self.assertEqual([c["field_id"] for c in self.sent()], ["customfield_10201", "customfield_10201"])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
|
||||
|
||||
def test_hub_422_degrades(self): # Review Focus 3
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.hub.reject_history = (422, {"error": "bad_history", "detail": "changes[0].seq"})
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(out["result"]["history"]["error"].startswith("хаб: "))
|
||||
self.assertNotIn("retryable", out)
|
||||
|
||||
def test_hub_down_fails(self):
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
self.hub.reject_history = "drop"
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"])
|
||||
|
||||
def test_batches_of_500(self):
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
for n in range(600):
|
||||
self.fake.add_history("X", "2026-10-22T12:10:00.000+0300", [_st("status", "a", "b")])
|
||||
out = self.refresh({"since": SINCE})
|
||||
self.assertEqual([len(b["changes"]) for b in self.hub.histories], [500, 100])
|
||||
self.assertEqual(out["result"]["history"]["changes"], 600)
|
||||
|
||||
def test_no_history_param(self): # «Хаб без журнала»
|
||||
self.fake.add("X", updated=CHANGED)
|
||||
out = self.refresh()
|
||||
self.assertNotIn("history", out["result"])
|
||||
self.assertEqual(self.changelog_queries(), [])
|
||||
|
||||
def test_version(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 16)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@ -5,6 +5,11 @@ from fake_hub import FakeHub
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
|
||||
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
|
||||
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
|
||||
|
||||
|
||||
class RefreshTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
@ -81,6 +86,140 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
self.assertEqual(self.hub.states, [])
|
||||
|
||||
def test_comments_in_state(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add("AURORA-2")
|
||||
self.fake.add_comment("AURORA-1", "первый\nвторая строка", name="petrov", display="Петров Пётр",
|
||||
created="2026-10-14T10:00:00.000+0300")
|
||||
self.fake.add_comment("AURORA-1", "второй", name="ivanov", display=None, created="2026-10-14T11:00:00.000+0300",
|
||||
updated="2026-10-14T12:00:00.000+0300")
|
||||
self.fake.add_comment("AURORA-1", None, name=None, display=None) # автор удалён, текста нет
|
||||
out = self.ex.run({"id": 8, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
c = by_key["AURORA-1"]["comments"]
|
||||
self.assertEqual([x["author"] for x in c], ["Петров Пётр", "ivanov", None])
|
||||
self.assertEqual(c[0], {"id": c[0]["id"], "author": "Петров Пётр", "created": "2026-10-14T10:00:00.000+0300",
|
||||
"updated": "2026-10-14T10:00:00.000+0300", "body": "первый\nвторая строка"})
|
||||
self.assertIsInstance(c[0]["id"], str)
|
||||
self.assertEqual(c[1]["updated"], "2026-10-14T12:00:00.000+0300")
|
||||
self.assertEqual(c[2]["body"], "")
|
||||
self.assertEqual(by_key["AURORA-2"]["comments"], [])
|
||||
self.assertIn("comment", self.ex.state_fields().split(","))
|
||||
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||||
self.assertEqual(len(searches), 1) # комментарии пришли тем же search
|
||||
self.assertFalse(any(r[1].endswith("/comment") for r in self.fake.requests))
|
||||
|
||||
def test_refresh_attachments_in_state(self): # сценарий «Вложения в состоянии»
|
||||
self.fake.add("AURORA-1650")
|
||||
self.fake.add_attachment("AURORA-1650", "скрин 1.png", b"x" * 240000, mime="image/png")
|
||||
self.fake.add_attachment("AURORA-1650", "ОСВ 60.xlsx", b"y" * 10, name="ivanov", display=None)
|
||||
out = self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1650"]}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
st = self.hub.states[0]["issues"][0]
|
||||
a = st["attachments"]
|
||||
self.assertEqual([x["filename"] for x in a], ["скрин 1.png", "ОСВ 60.xlsx"])
|
||||
self.assertEqual(a[0], {"id": a[0]["id"], "filename": "скрин 1.png", "size": 240000, "mime_type": "image/png",
|
||||
"author": "Потапова Анна", "created": "2026-10-20T09:15:00.000+0300"})
|
||||
self.assertIsInstance(a[0]["id"], str)
|
||||
self.assertEqual(a[1]["author"], "ivanov")
|
||||
self.assertIn("attachment", self.ex.state_fields().split(","))
|
||||
self.assertEqual(sum(1 for r in self.fake.requests if r[1].endswith("/search")), 1)
|
||||
self.assertFalse(any("/secure/attachment/" in r[1] for r in self.fake.requests))
|
||||
|
||||
def test_refresh_attachment_without_id_dropped(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add_attachment("AURORA-1", "a.txt", b"a")
|
||||
self.fake.add_attachment("AURORA-1", "b.txt", b"b")
|
||||
self.fake.attachments["AURORA-1"][0]["id"] = None
|
||||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
a = self.hub.states[0]["issues"][0]["attachments"]
|
||||
self.assertEqual([x["filename"] for x in a], ["b.txt"])
|
||||
|
||||
def test_refresh_no_attachments_empty_list(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add("AURORA-2")
|
||||
self.fake.add_attachment("AURORA-2", "a.txt", b"a")
|
||||
self.fake.attachments["AURORA-2"][0]["size"] = None
|
||||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual(by_key["AURORA-1"]["attachments"], [])
|
||||
self.assertIsNone(by_key["AURORA-2"]["attachments"][0]["size"])
|
||||
self.fake.hide_attachment_field = True
|
||||
self.ex.run({"id": 6, "type": "refresh", "params": {"keys": ["AURORA-2"]}})
|
||||
self.assertEqual(self.hub.states[-1]["issues"][0]["attachments"], [])
|
||||
|
||||
def test_comments_partial_list_is_read_fully(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for n in range(130):
|
||||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||||
self.fake.search_comment_cap = 20
|
||||
out = self.ex.run({"id": 9, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
self.assertEqual(out["status"], "done")
|
||||
c = self.hub.states[0]["issues"][0]["comments"]
|
||||
self.assertEqual(len(c), 130)
|
||||
self.assertEqual([x["body"] for x in c[:2]] + [c[-1]["body"]], ["к0", "к1", "к129"])
|
||||
reads = [r for r in self.fake.requests if r == ("GET", "/rest/api/2/issue/AURORA-1/comment")]
|
||||
self.assertEqual(len(reads), 2) # 100 + 30
|
||||
|
||||
def test_comments_field_missing(self):
|
||||
raw = {"key": "AURORA-1", "fields": {}} # поле comment недоступно
|
||||
self.assertEqual(self.ex.comments_of(raw), [])
|
||||
raw = {"key": "AURORA-1", "fields": {"comment": {"comments": [{"id": "5", "body": "x"}]}}} # total нет
|
||||
self.assertEqual(self.ex.comments_of(raw),
|
||||
[{"id": "5", "author": None, "created": None, "updated": None, "body": "x"}])
|
||||
self.assertEqual(self.fake.requests, []) # без дочитывания
|
||||
|
||||
def test_comment_without_id_dropped(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add_comment("AURORA-1", "нормальный")
|
||||
self.fake.add_comment("AURORA-1", "второй")["id"] = None
|
||||
self.fake.add_comment("AURORA-1", "третий")["id"] = ""
|
||||
logs = []
|
||||
orig = agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
out = self.ex.run({"id": 10, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
finally:
|
||||
agent.log = orig
|
||||
self.assertEqual(out["status"], "done")
|
||||
c = self.hub.states[0]["issues"][0]["comments"]
|
||||
self.assertEqual([x["body"] for x in c], ["нормальный"])
|
||||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "комментарий без id" in l]), 2, logs)
|
||||
|
||||
def test_comments_read_denied_keeps_refresh(self):
|
||||
for key in ("AURORA-1", "AURORA-2", "AURORA-3"):
|
||||
self.fake.add(key)
|
||||
for n in range(3):
|
||||
self.fake.add_comment(key, "%s-%d" % (key, n))
|
||||
self.fake.search_comment_cap = 1 # search отдаёт 1 из 3 — нужно дочитывание
|
||||
self.fake.comment_denied = {"AURORA-1": 403, "AURORA-2": 404}
|
||||
logs = []
|
||||
orig = agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-3"]}})
|
||||
finally:
|
||||
agent.log = orig
|
||||
self.assertEqual(out["status"], "done")
|
||||
by_key = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertNotIn("comments", by_key["AURORA-1"])
|
||||
self.assertNotIn("comments", by_key["AURORA-2"])
|
||||
self.assertEqual([x["body"] for x in by_key["AURORA-3"]["comments"]], ["AURORA-3-0", "AURORA-3-1", "AURORA-3-2"])
|
||||
self.assertTrue(any("AURORA-1" in l and "HTTP 403" in l for l in logs), logs)
|
||||
self.assertTrue(any("AURORA-2" in l and "HTTP 404" in l for l in logs), logs)
|
||||
|
||||
def test_comments_read_5xx_still_fails_refresh(self):
|
||||
self.fake.add("AURORA-1")
|
||||
for n in range(3):
|
||||
self.fake.add_comment("AURORA-1", "к%d" % n)
|
||||
self.fake.search_comment_cap = 1
|
||||
self.fake.comment_denied = {"AURORA-1": 500}
|
||||
out = self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertTrue(out["retryable"]) # 5xx — по-прежнему повтор через проверку (R-10)
|
||||
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")
|
||||
@ -124,6 +263,12 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"]["missing"], ["GONE-1"])
|
||||
|
||||
def test_agent_version_16(self):
|
||||
self.assertEqual(agent.AGENT_VERSION, 16)
|
||||
self.fake.add("AURORA-1")
|
||||
self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||
self.assertEqual(self.hub.states[0]["agent_version"], 16)
|
||||
|
||||
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"]}})
|
||||
@ -171,7 +316,7 @@ class RefreshTest(unittest.TestCase):
|
||||
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)])
|
||||
names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])
|
||||
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)
|
||||
@ -202,11 +347,358 @@ class RefreshTest(unittest.TestCase):
|
||||
self.assertFalse(out["retryable"])
|
||||
|
||||
def test_jira_error_has_no_peer(self):
|
||||
self.ex.jira_fields()
|
||||
self.fake.fail_next.append((500, {}))
|
||||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
|
||||
self.assertTrue(out["retryable"])
|
||||
self.assertNotIn("peer", out)
|
||||
|
||||
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
||||
|
||||
def refresh(self, **params):
|
||||
return self.ex.run({"id": 5, "type": "refresh", "params": params})
|
||||
|
||||
def logged(self, fn):
|
||||
logs, orig = [], agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
return fn(), logs
|
||||
finally:
|
||||
agent.log = orig
|
||||
|
||||
def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач
|
||||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s}
|
||||
for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))]
|
||||
self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0)
|
||||
self.fake.add("AURORA-2")
|
||||
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]),
|
||||
("0|i0001:", 3.0, [2, 3]))
|
||||
self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, []))
|
||||
fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0]
|
||||
for f in ("customfield_10104", "customfield_10105", "customfield_10106"):
|
||||
self.assertIn(f, fields)
|
||||
|
||||
def test_sprint_field_server_strings(self):
|
||||
self.fake.add("AURORA-1", sprint_raw=[
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]",
|
||||
"com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]",
|
||||
"мусор", {"id": "74"}, {"name": "без id"}])
|
||||
out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"]))
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74])
|
||||
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs)
|
||||
|
||||
def test_story_points_non_number_is_null(self):
|
||||
for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)):
|
||||
self.fake.add(key, customfield_10106=v)
|
||||
self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"])
|
||||
self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5])
|
||||
|
||||
def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh
|
||||
self.fake.fields = []
|
||||
for i in range(1, 121):
|
||||
self.fake.add("AURORA-%d" % i, customfield_10106=2)
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL))
|
||||
self.assertEqual(out["result"]["pages"], 2)
|
||||
for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]:
|
||||
self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st)
|
||||
for name in ("rank", "sprint_ids", "story_points"):
|
||||
self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs)
|
||||
|
||||
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
paths = [r[1] for r in self.fake.requests]
|
||||
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
|
||||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_board_not_found(self): # сценарий «Доска не найдена»
|
||||
self.fake.add("AURORA-1")
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"9999": None})
|
||||
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
|
||||
self.assertEqual(len(self.hub.states), 1)
|
||||
|
||||
def test_new_scrum_board_sprints_read(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
self.fake.add("AURORA-1", sprint="Спринт K")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||||
|
||||
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL)
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
self.assertNotIn("boards", self.hub.states[0])
|
||||
self.assertFalse(any(r[1].endswith("/configuration") for r in self.fake.requests))
|
||||
|
||||
def test_known_boards_no_type_requests(self):
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_empty_boards_no_settings_fallback(self):
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertNotIn("boards", out["result"])
|
||||
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
|
||||
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
|
||||
|
||||
def test_board_type_5xx_logged_refresh_continues(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": None})
|
||||
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
|
||||
self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб
|
||||
|
||||
def test_board_other_type_is_null(self):
|
||||
self.fake.boards[7000] = "simple"
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
|
||||
self.assertEqual(out["result"]["boards"], {"7000": None})
|
||||
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
|
||||
|
||||
def test_duplicate_null_board_requested_once(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
|
||||
|
||||
def test_empty_keys_still_resolves_boards(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 1, "boards": {"5120": "kanban"}})
|
||||
self.assertEqual(self.hub.states, [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [],
|
||||
"neighbor_keys": [], "page": 1, "last_page": True,
|
||||
"boards": [{"id": 5120, "type": "kanban", "columns": COLS,
|
||||
"kanban_backlog": True, "sprints": []}]}])
|
||||
|
||||
def test_last_found_sprint_wins_across_boards(self):
|
||||
self.fake.boards[5120] = "scrum"
|
||||
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
|
||||
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
|
||||
self.refresh(jql=self.JQL, boards=both)
|
||||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
|
||||
self.refresh(jql=self.JQL, boards=both[::-1])
|
||||
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36
|
||||
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску
|
||||
self.fake.add("AURORA-1")
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 404", out["error"])
|
||||
self.assertIn("доска 9999", out["error"])
|
||||
self.assertFalse(out["retryable"])
|
||||
self.assertEqual(self.hub.states, [])
|
||||
|
||||
def test_non_dict_board_item_skipped(self): # M-2
|
||||
self.fake.boards[5120] = "kanban"
|
||||
out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
||||
|
||||
def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами»
|
||||
sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw)
|
||||
self.fake.board_sprints[4216] = [
|
||||
sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00",
|
||||
completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"),
|
||||
sp(2, "closed"), sp(3, "active"), sp(4, "future")]
|
||||
for i in range(1, 121):
|
||||
self.fake.add("AURORA-%d" % i, sprint="S3")
|
||||
self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3]
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
b = self.hub.states[0]["boards"]
|
||||
self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)])
|
||||
self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4])
|
||||
self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed",
|
||||
"start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00",
|
||||
"complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"})
|
||||
self.assertNotIn("boards", self.hub.states[1])
|
||||
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
|
||||
self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3"))
|
||||
sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
|
||||
self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка
|
||||
self.assertNotIn("state", sprint_reads[0])
|
||||
|
||||
def test_all_sprints_paged(self):
|
||||
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": "closed"} for i in range(1, 121)]
|
||||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||||
reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
|
||||
self.assertEqual(len(reads), 3)
|
||||
self.assertEqual(len(self.hub.states[0]["boards"][0]["sprints"]), 120)
|
||||
|
||||
def test_kanban_backlog_flag(self):
|
||||
self.fake.boards[5120] = "kanban"
|
||||
for code, want in ((200, True), (400, False), (500, None)):
|
||||
self.fake.backlog_codes[5120] = code
|
||||
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
|
||||
b = self.hub.states[-1]["boards"][0]
|
||||
self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS))
|
||||
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests])
|
||||
|
||||
def test_kanban_config_error_backlog_null(self): # M-1: columns null → kanban_backlog null, запроса бэклога нет
|
||||
self.fake.boards[5120] = "kanban"
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/5120/configuration"] = [403]
|
||||
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
|
||||
b = self.hub.states[-1]["boards"][0]
|
||||
self.assertEqual((b["columns"], b["kanban_backlog"]), (None, None))
|
||||
self.assertNotIn("/rest/agile/1.0/board/5120/backlog", [r[1] for r in self.fake.requests])
|
||||
|
||||
def test_board_config_error_columns_null(self):
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
b = self.hub.states[0]["boards"][0]
|
||||
self.assertIsNone(b["columns"])
|
||||
self.assertEqual([s["id"] for s in b["sprints"]], [1, 2])
|
||||
self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs)
|
||||
|
||||
def test_board_sprints_error_board_omitted(self): # R-65
|
||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||
self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен
|
||||
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.hub.states[0]["boards"], [])
|
||||
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
|
||||
|
||||
def test_board_bad_sprint_dropped(self): # R-72
|
||||
self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"},
|
||||
{"id": 3, "state": "active"}]
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
|
||||
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
|
||||
|
||||
def test_unknown_status_id_skipped_and_status_cached(self): # C-1: один перечитывающий GET /status за команду + лог
|
||||
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"]), ("Две", ["99998"])]
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(self.hub.states[0]["boards"][0]["columns"],
|
||||
[{"name": "Одна", "statuses": ["Разработка"]}, {"name": "Две", "statuses": []}])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 2)
|
||||
self.assertTrue(any("4216" in l and "99999" in l and "«Одна»" in l and "не найден" in l for l in logs), logs)
|
||||
self.assertTrue(any("99998" in l and "«Две»" in l for l in logs), logs)
|
||||
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 3)
|
||||
|
||||
def test_unknown_status_found_after_reread(self): # статус добавлен в Jira после заполнения кэша — не теряется
|
||||
self.ex.status_names()
|
||||
self.fake.statuses["10010"] = "Принято"
|
||||
self.fake.board_columns[4216] = [("Одна", ["10003", "10010"])]
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка", "Принято"]}])
|
||||
self.assertEqual([l for l in logs if "не найден" in l], [])
|
||||
|
||||
def test_board_bad_types_dropped(self): # M-3: id/name/даты/goal строже, как check_boards хаба
|
||||
bad = [{"id": "5", "name": "A", "state": "active"}, {"id": True, "name": "B", "state": "active"},
|
||||
{"id": 0, "name": "C", "state": "active"}, {"id": 6, "name": 7, "state": "active"},
|
||||
{"id": 8, "name": "Ok", "state": "future", "startDate": 123, "endDate": "2026-10-28T09:00:00.000+0300",
|
||||
"completeDate": [], "goal": 5}]
|
||||
self.fake.board_sprints[4216] = bad
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(out["status"], "done", out)
|
||||
self.assertEqual(self.hub.states[0]["boards"][0]["sprints"],
|
||||
[{"id": 8, "name": "Ok", "state": "future", "start_date": None,
|
||||
"end_date": "2026-10-28T09:00:00.000+0300", "complete_date": None, "goal": None}])
|
||||
self.assertEqual(len([l for l in logs if "пропущен" in l]), 4, logs)
|
||||
|
||||
def test_column_without_string_name_skipped(self): # M-3
|
||||
self.fake.board_columns[4216] = [(None, ["10003"]), ("Ок", ["10003"])]
|
||||
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
|
||||
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Ок", "statuses": ["Разработка"]}])
|
||||
self.assertTrue(any("4216" in l and "колонка" in l and "пропущена" in l for l in logs), logs)
|
||||
|
||||
def test_empty_jql_with_boards_sends_empty_batch(self): # R-66
|
||||
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
|
||||
self.assertEqual(out["result"]["pages"], 1)
|
||||
st = self.hub.states[0]
|
||||
self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True))
|
||||
self.assertEqual(st["boards"][0]["id"], 4216)
|
||||
|
||||
def test_sprint_ids_non_ascii_digits(self):
|
||||
self.assertEqual(self.ex.sprint_ids_of("AURORA-1", [{"id": "²"}, {"id": "٣"}, {"id": "7"}]), [7])
|
||||
|
||||
|
||||
class JiraFieldsTest(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 logged(self, fn):
|
||||
logs, orig = [], agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
return fn(), logs
|
||||
finally:
|
||||
agent.log = orig
|
||||
|
||||
def test_fields_found_by_schema_and_name(self):
|
||||
want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"}
|
||||
self.assertEqual(self.ex.jira_fields(), want)
|
||||
self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив
|
||||
self.assertEqual(self.ex.jira_fields(), want)
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
|
||||
|
||||
def test_fields_override_skips_lookup(self):
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p")
|
||||
self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"})
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
|
||||
def test_fields_partial_override(self):
|
||||
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
|
||||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000")
|
||||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||||
|
||||
def test_story_points_first_by_numeric_id_any_case(self):
|
||||
self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True},
|
||||
{"id": "customfield_10050", "name": "Story Points", "custom": True}]
|
||||
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999")
|
||||
|
||||
def test_field_lookup_error_retried_next_command(self):
|
||||
self.fake.fail_next.append((403, {}))
|
||||
out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest
|
||||
self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None})
|
||||
self.assertTrue(any("HTTP 403" in l for l in logs), logs)
|
||||
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем
|
||||
self.ex.run({"id": 2, "type": "nope", "params": {}})
|
||||
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
@ -580,6 +583,62 @@ class RunnerTest(unittest.TestCase):
|
||||
self.assertTrue(any("обрыв связи с хабом, повтор 1/3" in l for l in logs), logs)
|
||||
self.assertNotIn("peer", r)
|
||||
|
||||
def test_attachment_upload_dropped_no_duplicate(self):
|
||||
sha = self.hub.put_file(b"abc")
|
||||
self.fake.drop_next_post_attachment = True # Jira сохранила вложение, ответ потерян
|
||||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "ОСВ 60.xlsx"})
|
||||
self.runner.loop()
|
||||
r = self.hub.results[1]
|
||||
self.assertEqual(r["status"], "done", r)
|
||||
self.assertTrue(r["result"]["existed"])
|
||||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||||
self.assertEqual(r["result"]["attachment_id"], self.fake.attachments["AURORA-1"][0]["id"])
|
||||
self.assertEqual(self.hub.file_requests, 1) # повтор нашёл вложение, файл второй раз не нужен
|
||||
self.assertAlmostEqual(sum(self.sleeps), 30, delta=1.5)
|
||||
self.assertEqual(r["jira_calls"], 2) # GET + повторный GET (оборванный POST не считается)
|
||||
|
||||
def test_attachment_corrupted_file_retried_as_hub_drop(self):
|
||||
sha = hashlib.sha256(b"abc").hexdigest()
|
||||
self.hub.files[sha] = b"xyz" # размер тот же, содержимое другое
|
||||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "a.txt"})
|
||||
self.runner.loop()
|
||||
r = self.hub.results[1]
|
||||
self.assertEqual(r["status"], "failed")
|
||||
self.assertTrue(r["error"].startswith("результат неизвестен (обрыв связи с хабом), повтор безопасен: хаб: "),
|
||||
r["error"])
|
||||
self.assertEqual(self.hub.file_requests, 4) # первый запуск + 3 повтора
|
||||
self.assertFalse(any(m == "POST" for m, _ in self.fake.requests))
|
||||
self.assertEqual(self.fake.attachments, {})
|
||||
|
||||
def test_attachment_file_gone_at_claim(self):
|
||||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": "0" * 64, "filename": "a.txt"})
|
||||
self.runner.loop()
|
||||
r = self.hub.results[1]
|
||||
self.assertEqual((r["status"], r["error"]), ("failed", "файл удалён с хаба, загрузите заново"))
|
||||
self.assertEqual(r["jira_calls"], 0)
|
||||
self.assertEqual(self.fake.requests, [])
|
||||
|
||||
def test_attachment_content_not_in_journal_or_log(self):
|
||||
data = b"SECRET-CONTENT-" + bytes(range(256)) * 4
|
||||
sha = self.hub.put_file(data)
|
||||
secret_b64 = base64.b64encode(data).decode("ascii")
|
||||
self.hub.enqueue("ensure_attachment", {"key": "AURORA-1", "file": sha, "filename": "отчёт.bin"})
|
||||
logs = []
|
||||
orig = agent.log
|
||||
agent.log = logs.append
|
||||
try:
|
||||
self.runner.loop()
|
||||
finally:
|
||||
agent.log = orig
|
||||
self.assertEqual(self.hub.results[1]["status"], "done")
|
||||
with open(self.journal.path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
text += "\n".join(logs) + json.dumps(self.hub.results[1], ensure_ascii=False)
|
||||
for needle in ("SECRET-CONTENT", secret_b64[:40], secret_b64[-40:]):
|
||||
self.assertNotIn(needle, text)
|
||||
self.assertTrue(any(sha in l and "отчёт.bin" in l for l in logs), logs) # file и filename — в логе
|
||||
self.assertTrue(any(str(len(data)) in l for l in logs), logs) # размер — в логе
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user