Агент v15: спринты, ранг, оценки (#49) #50
@ -11,7 +11,7 @@ Jira и хаба. Спека: `../sdlc-hub/openspec/changes/bridge-core/`, пл
|
|||||||
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` |
|
| HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `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` |
|
| Папка | `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 — под блокировкой журнала) | файловая система |
|
| Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система |
|
||||||
| Исполнитель | `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` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `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` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
|
||||||
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` |
|
||||||
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
| Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` |
|
||||||
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
| CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` |
|
||||||
@ -43,4 +43,4 @@ agent-v9-hardening); коды агента: 75 — подмена, 73 — жур
|
|||||||
|
|
||||||
## Открытые вопросы
|
## Открытые вопросы
|
||||||
- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 2).
|
- HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 2).
|
||||||
- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6).
|
- Поле `sprint` задач закрытых спринтов: `sprint = null` (рулинг R-6); полная история спринтов — в `sprint_ids` (v15).
|
||||||
|
|||||||
@ -39,7 +39,7 @@
|
|||||||
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
|
- `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`.
|
||||||
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
|
Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`.
|
||||||
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
|
- Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`),
|
||||||
`test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `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_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429),
|
||||||
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
|
`test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах),
|
||||||
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
|
`test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки).
|
||||||
@ -48,7 +48,7 @@
|
|||||||
## Первая подсистема
|
## Первая подсистема
|
||||||
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд,
|
||||||
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`.
|
||||||
Последний change — `../sdlc-hub/openspec/changes/attachment-fetch/` (агент v14: список вложений в `refresh`, команда `fetch_attachment`; решения агента — `openspec/changes/agent-v14-attachment-fetch/design.md`), план — `docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md`.
|
Последний change — `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг, оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — `docs/superpowers/plans/2026-10-24-sprints-backlog-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`.
|
План и спека живут в `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`.
|
||||||
|
|
||||||
## Выкатка новой версии
|
## Выкатка новой версии
|
||||||
|
|||||||
14
README.md
14
README.md
@ -168,14 +168,17 @@ python3 agent.py prune
|
|||||||
| Тип | Что делает |
|
| Тип | Что делает |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` |
|
| `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` |
|
||||||
| `ensure_fields` | читает задачу и пишет только отличающиеся поля |
|
| `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка) |
|
||||||
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
|
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
|
||||||
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
|
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
|
||||||
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
|
||||||
| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом 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` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
|
| `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». Содержимое файла в журнал и лог не пишется |
|
| `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 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
|
| `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-досок |
|
||||||
|
|
||||||
Правила работы:
|
Правила работы:
|
||||||
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
|
||||||
@ -193,7 +196,7 @@ python3 agent.py prune
|
|||||||
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
|
||||||
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
|
||||||
по сигналу команду подхватит восстановление.
|
по сигналу команду подхватит восстановление.
|
||||||
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте
|
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 15) отправляется в каждом `claim`, в каждом отчёте
|
||||||
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
(`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени
|
||||||
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`.
|
||||||
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
|
||||||
@ -209,6 +212,11 @@ python3 agent.py prune
|
|||||||
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
|
Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`);
|
||||||
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
|
перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на
|
||||||
`fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»).
|
`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`.
|
||||||
|
Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает
|
||||||
|
«неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки.
|
||||||
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
|
||||||
при этом выдаются по-прежнему.
|
при этом выдаются по-прежнему.
|
||||||
|
|
||||||
|
|||||||
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).
|
||||||
2
openspec/changes/agent-v15-sprints/.openspec.yaml
Normal file
2
openspec/changes/agent-v15-sprints/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-24
|
||||||
81
openspec/changes/agent-v15-sprints/design.md
Normal file
81
openspec/changes/agent-v15-sprints/design.md
Normal file
@ -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), не по смыслу |
|
||||||
469
src/agent.py
469
src/agent.py
@ -33,7 +33,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
AGENT_VERSION = 14
|
AGENT_VERSION = 15
|
||||||
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
|
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
|
||||||
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new
|
||||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
@ -641,6 +641,31 @@ def _nl(value) -> str:
|
|||||||
return (value or "").replace("\r\n", "\n")
|
return (value or "").replace("\r\n", "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _same_points(a, b) -> bool:
|
||||||
|
"""Равны ли оценки story_points: None равно только None; числа — с допуском 1e-9 (3 == 3.0); bool — не число."""
|
||||||
|
if a is None or b is None:
|
||||||
|
return a is None and b is None
|
||||||
|
if isinstance(a, bool) or isinstance(b, bool) or not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
|
||||||
|
return False
|
||||||
|
return abs(a - b) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _is_day(value: Any) -> bool:
|
||||||
|
"""Строка ГГГГ-ММ-ДД, которую разбирает strptime (28.10.2026 и 2026-13-01 — нет)."""
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
datetime.strptime(value, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _sprint_time(day: str, hour: int) -> str:
|
||||||
|
"""День спринта → время Jira (`ГГГГ-ММ-ДДTЧЧ:00:00.000+ЗЗЗЗ`) со смещением локального часового пояса (R-68)."""
|
||||||
|
return "%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z"))
|
||||||
|
|
||||||
|
|
||||||
class ConfigGap(Exception):
|
class ConfigGap(Exception):
|
||||||
"""В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле."""
|
"""В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле."""
|
||||||
|
|
||||||
@ -669,16 +694,31 @@ def _fmt_size(n: Optional[int]) -> str:
|
|||||||
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
|
return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
RANK_SCHEMA = "com.pyxis.greenhopper.jira:gh-lexo-rank" # схема поля «Rank» (ранг Lexorank)
|
||||||
|
SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint" # схема поля «Sprint»
|
||||||
|
|
||||||
|
|
||||||
class Executor:
|
class Executor:
|
||||||
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
||||||
self.jira, self.s, self.hub = jira, settings, hub
|
self.jira, self.s, self.hub = jira, settings, hub
|
||||||
self.sleep = time.sleep
|
self.sleep = time.sleep
|
||||||
self._symmetric = None
|
self._symmetric = None
|
||||||
self._sprints = {} # str(доска) → имя → спринт; на одну команду
|
self._sprints = {} # str(доска) → имя → спринт; на одну команду
|
||||||
|
self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду
|
||||||
|
self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor
|
||||||
|
self._status_reread = False # /status уже перечитан в этой команде (неизвестный id статуса, C-1)
|
||||||
|
self._field_list = None # GET /field: список полей на одну команду
|
||||||
|
self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields
|
||||||
|
self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor
|
||||||
|
|
||||||
def run(self, cmd: dict) -> dict:
|
def run(self, cmd: dict) -> dict:
|
||||||
self.jira.reset_counters()
|
self.jira.reset_counters()
|
||||||
self._sprints = {}
|
self._sprints = {}
|
||||||
|
self._all_sprints = {}
|
||||||
|
self._status_reread = False
|
||||||
|
self._field_list = None
|
||||||
|
if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67)
|
||||||
|
self._jira_fields = None
|
||||||
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
||||||
if handler is None:
|
if handler is None:
|
||||||
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
||||||
@ -724,6 +764,47 @@ class Executor:
|
|||||||
def has_field(self, logical: str) -> bool:
|
def has_field(self, logical: str) -> bool:
|
||||||
return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))
|
return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))
|
||||||
|
|
||||||
|
def field_list(self) -> List[dict]:
|
||||||
|
"""GET /rest/api/2/field — один раз на команду; только элементы-словари. Ошибки Jira и связи — наружу."""
|
||||||
|
if self._field_list is None:
|
||||||
|
self._field_list = [f for f in self.jira.get(API + "/field") or [] if isinstance(f, dict)]
|
||||||
|
return self._field_list
|
||||||
|
|
||||||
|
def jira_fields(self) -> Dict[str, Optional[str]]:
|
||||||
|
"""Поля ранга, спринта и оценки: {"rank", "sprint", "story_points"} → id или None. Настроенный в field_ids id
|
||||||
|
берётся как есть; остальные ищутся в field_list: ранг и спринт по схеме, оценка по имени «story points» (регистр
|
||||||
|
не важен), при нескольких — наименьший (len(id), id). Ошибка HTTP — лог, None, поиск повторится в следующей
|
||||||
|
команде; связь — наружу."""
|
||||||
|
if self._jira_fields is not None:
|
||||||
|
return self._jira_fields
|
||||||
|
ids = self.s.get("field_ids") or {}
|
||||||
|
over = {k: ids.get(k) or None for k in ("rank", "sprint", "story_points")}
|
||||||
|
if all(over.values()):
|
||||||
|
self._jira_fields, self._jira_fields_ok = dict(over), True
|
||||||
|
return self._jira_fields
|
||||||
|
try:
|
||||||
|
fields = self.field_list()
|
||||||
|
except HttpError as e:
|
||||||
|
log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % e.code)
|
||||||
|
self._jira_fields, self._jira_fields_ok = over, False
|
||||||
|
return self._jira_fields
|
||||||
|
|
||||||
|
def find(match) -> Optional[str]:
|
||||||
|
cands = sorted((f for f in fields if match(f)), key=lambda f: (len(str(f.get("id"))), str(f.get("id"))))
|
||||||
|
return cands[0].get("id") if cands else None
|
||||||
|
|
||||||
|
def by_schema(schema: str):
|
||||||
|
return lambda f: isinstance(f.get("schema"), dict) and f["schema"].get("custom") == schema
|
||||||
|
|
||||||
|
def by_name(name: str):
|
||||||
|
return lambda f: str(f.get("name") or "").strip().casefold() == name
|
||||||
|
|
||||||
|
found = {"rank": find(by_schema(RANK_SCHEMA)), "sprint": find(by_schema(SPRINT_SCHEMA)),
|
||||||
|
"story_points": find(by_name("story points"))}
|
||||||
|
self._jira_fields = {k: over[k] or found[k] for k in found}
|
||||||
|
self._jira_fields_ok = True
|
||||||
|
return self._jira_fields
|
||||||
|
|
||||||
def priority_value(self, name: str) -> dict:
|
def priority_value(self, name: str) -> dict:
|
||||||
mapped = (self.s.get("priority_map") or {}).get(name, name)
|
mapped = (self.s.get("priority_map") or {}).get(name, name)
|
||||||
return {self.s.get("priority_value_key") or "name": mapped}
|
return {self.s.get("priority_value_key") or "name": mapped}
|
||||||
@ -792,6 +873,15 @@ class Executor:
|
|||||||
# --- ensure_fields ---------------------------------------------------------
|
# --- ensure_fields ---------------------------------------------------------
|
||||||
def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
|
def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
|
||||||
key = p["key"]
|
key = p["key"]
|
||||||
|
sp_fid = None
|
||||||
|
if "story_points" in p:
|
||||||
|
# Настроенный id берётся без GET /field (как в refresh он не нужен); иначе — поиск в jira_fields.
|
||||||
|
sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"]
|
||||||
|
if not sp_fid:
|
||||||
|
raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points")
|
||||||
|
want_sp = p["story_points"]
|
||||||
|
if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0):
|
||||||
|
return self._out("failed", error="story_points — число ≥ 0 или null")
|
||||||
cur = self.jira.get(API + "/issue/%s" % key)["fields"]
|
cur = self.jira.get(API + "/issue/%s" % key)["fields"]
|
||||||
fields, changed = {}, []
|
fields, changed = {}, []
|
||||||
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
|
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
|
||||||
@ -830,6 +920,9 @@ class Executor:
|
|||||||
if new != labels:
|
if new != labels:
|
||||||
fields[self.fid("labels")] = new
|
fields[self.fid("labels")] = new
|
||||||
changed.append("labels")
|
changed.append("labels")
|
||||||
|
if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]):
|
||||||
|
fields[sp_fid] = p["story_points"]
|
||||||
|
changed.append("story_points")
|
||||||
if not fields:
|
if not fields:
|
||||||
return self._out("done", {"changed": []})
|
return self._out("done", {"changed": []})
|
||||||
self.jira.put(API + "/issue/%s" % key, {"fields": fields})
|
self.jira.put(API + "/issue/%s" % key, {"fields": fields})
|
||||||
@ -988,6 +1081,178 @@ class Executor:
|
|||||||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||||||
return self._out("done", {"sprint": want, "from": cur})
|
return self._out("done", {"sprint": want, "from": cur})
|
||||||
|
|
||||||
|
# --- ensure_sprint_meta ----------------------------------------------------
|
||||||
|
def sprint_board(self, p: dict) -> Any:
|
||||||
|
"""Доска из команды; без неё спринты недоступны (R-70): доска из настроек хаба не подменяет команду."""
|
||||||
|
board = p.get("board_id")
|
||||||
|
if not board:
|
||||||
|
raise ConfigGap("board_id нет в команде — спринты недоступны")
|
||||||
|
return board
|
||||||
|
|
||||||
|
def sprints_named(self, board: Any, name: str) -> List[dict]:
|
||||||
|
"""Все спринты доски board (любых состояний) с точным именем name."""
|
||||||
|
return [sp for sp in self.all_sprints(board) if sp.get("name") == name]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def many_sprints(name: str, found: List[dict]) -> str:
|
||||||
|
return "несколько спринтов с именем «%s»: %s" % (name, ", ".join(str(sp["id"]) for sp in found))
|
||||||
|
|
||||||
|
def do_ensure_sprint_meta(self, p: dict, cmd: dict) -> dict:
|
||||||
|
"""Имя, даты и цель спринта доски. Даты проверяются первыми; спринт ищется по `sprint`, а при `rename_to` —
|
||||||
|
по нему же (R-69): повтор после переименования находит спринт по новому имени; оба имени заняты — ошибка."""
|
||||||
|
for field in ("start_date", "end_date"):
|
||||||
|
if p.get(field) is not None and not _is_day(p[field]):
|
||||||
|
return self._out("failed", error="%s: дата не в формате ГГГГ-ММ-ДД: %r" % (field, p[field]))
|
||||||
|
board = self.sprint_board(p)
|
||||||
|
name, rename_to = p["sprint"], p.get("rename_to") or None
|
||||||
|
if rename_to == name:
|
||||||
|
rename_to = None
|
||||||
|
found = self.sprints_named(board, name)
|
||||||
|
if len(found) > 1:
|
||||||
|
return self._out("failed", error=self.many_sprints(name, found))
|
||||||
|
to = []
|
||||||
|
if rename_to:
|
||||||
|
to = [sp for sp in self.sprints_named(board, rename_to) if str(sp.get("state")).lower() != "closed"]
|
||||||
|
if len(to) > 1:
|
||||||
|
return self._out("failed", error=self.many_sprints(rename_to, to))
|
||||||
|
if found and to:
|
||||||
|
return self._out("failed", error="имя занято")
|
||||||
|
if not to and any(str(sp.get("state")).lower() == "closed" for sp in self.sprints_named(board, rename_to)):
|
||||||
|
return self._out("failed", error="имя занято") # M-2: имя только у закрытого спринта
|
||||||
|
cur = found[0] if found else (to[0] if to else None)
|
||||||
|
if cur is not None and str(cur.get("state")).lower() == "closed":
|
||||||
|
return self._out("failed", error="спринт закрыт")
|
||||||
|
target = rename_to or name
|
||||||
|
if cur is None:
|
||||||
|
body = {"name": target, "originBoardId": int(board)}
|
||||||
|
if p.get("start_date") is not None:
|
||||||
|
body["startDate"] = _sprint_time(p["start_date"], 9)
|
||||||
|
if p.get("end_date") is not None:
|
||||||
|
body["endDate"] = _sprint_time(p["end_date"], 18)
|
||||||
|
if p.get("goal") is not None:
|
||||||
|
body["goal"] = p["goal"]
|
||||||
|
created = self.jira.post(AGILE + "/sprint", body)
|
||||||
|
return self._out("done", {"sprint_id": created["id"], "created": True})
|
||||||
|
diff = {}
|
||||||
|
if cur.get("name") != target:
|
||||||
|
diff["name"] = target
|
||||||
|
if p.get("start_date") is not None and _date(cur.get("startDate")) != p["start_date"]:
|
||||||
|
diff["startDate"] = _sprint_time(p["start_date"], 9)
|
||||||
|
if p.get("end_date") is not None and _date(cur.get("endDate")) != p["end_date"]:
|
||||||
|
diff["endDate"] = _sprint_time(p["end_date"], 18)
|
||||||
|
if p.get("goal") is not None and (cur.get("goal") or "") != p["goal"]:
|
||||||
|
diff["goal"] = p["goal"]
|
||||||
|
if not diff:
|
||||||
|
return self._out("done", {"sprint_id": cur["id"], "created": False})
|
||||||
|
self.jira.post(AGILE + "/sprint/%s" % cur["id"], diff)
|
||||||
|
return self._out("done", {"sprint_id": cur["id"], "created": False})
|
||||||
|
|
||||||
|
# --- ensure_sprint_state ---------------------------------------------------
|
||||||
|
def sprint_issues(self, sprint_id: Any) -> List[Tuple[str, Optional[str]]]:
|
||||||
|
"""Все задачи спринта: (ключ, id статуса строкой или None, если Jira его не отдала), постранично по 50."""
|
||||||
|
out: List[Tuple[str, Optional[str]]] = []
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
page = self.jira.get(AGILE + "/sprint/%s/issue" % sprint_id, fields="status", startAt=start, maxResults=50)
|
||||||
|
items = page.get("issues") or []
|
||||||
|
for it in items:
|
||||||
|
sid = (((it.get("fields") or {}).get("status")) or {}).get("id")
|
||||||
|
out.append((it["key"], str(sid) if sid is not None else None))
|
||||||
|
start += len(items)
|
||||||
|
if not items or start >= int(page.get("total") or 0):
|
||||||
|
return out
|
||||||
|
|
||||||
|
def do_ensure_sprint_state(self, p: dict, cmd: dict) -> dict:
|
||||||
|
"""Запуск (active) или завершение (closed) спринта. Все проверки — до первой записи (R-71); при завершении
|
||||||
|
незавершённые задачи (id статуса не среди id статусов последней колонки доски, R-83) переносятся в `move_open_to` (backlog или другой
|
||||||
|
активный/будущий спринт той же доски), затем спринт закрывается. Спринт уже в нужном состоянии — ничего не пишем."""
|
||||||
|
state = p.get("state")
|
||||||
|
if state not in ("active", "closed"):
|
||||||
|
return self._out("failed", error="state — active или closed")
|
||||||
|
board = self.sprint_board(p)
|
||||||
|
name = p["sprint"]
|
||||||
|
found = self.sprints_named(board, name)
|
||||||
|
if not found:
|
||||||
|
return self._out("failed", error="нет спринта «%s» на доске %s" % (name, board))
|
||||||
|
if len(found) > 1:
|
||||||
|
return self._out("failed", error=self.many_sprints(name, found))
|
||||||
|
sp = found[0]
|
||||||
|
cur = str(sp.get("state")).lower()
|
||||||
|
if cur == state:
|
||||||
|
return self._out("done", {"sprint_id": sp["id"], "moved": []})
|
||||||
|
if state == "active":
|
||||||
|
if cur == "closed":
|
||||||
|
return self._out("failed", error="спринт закрыт")
|
||||||
|
if not sp.get("startDate") or not sp.get("endDate"):
|
||||||
|
return self._out("failed", error="у спринта нет дат: задайте ensure_sprint_meta")
|
||||||
|
self.jira.post(AGILE + "/sprint/%s" % sp["id"],
|
||||||
|
{"state": "active", "startDate": sp["startDate"], "endDate": sp["endDate"]})
|
||||||
|
return self._out("done", {"sprint_id": sp["id"], "moved": []})
|
||||||
|
if cur == "future":
|
||||||
|
return self._out("failed", error="спринт не начат")
|
||||||
|
to = p.get("move_open_to")
|
||||||
|
if not to:
|
||||||
|
return self._out("failed", error="move_open_to обязателен при state = closed")
|
||||||
|
target = None
|
||||||
|
if to != "backlog":
|
||||||
|
cands = [x for x in self.sprints_named(board, to)
|
||||||
|
if str(x.get("state")).lower() in ("active", "future") and x.get("id") != sp["id"]]
|
||||||
|
if len(cands) > 1:
|
||||||
|
return self._out("failed", error=self.many_sprints(to, cands))
|
||||||
|
if not cands:
|
||||||
|
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s" % (to, board))
|
||||||
|
target = cands[0]
|
||||||
|
cols = self.board_config_columns(board)
|
||||||
|
done_ids = set()
|
||||||
|
if cols:
|
||||||
|
done_ids = {str(st["id"]) for st in cols[-1].get("statuses") or [] if isinstance(st, dict) and st.get("id") is not None}
|
||||||
|
if not done_ids: # колонок нет, ошибка чтения или пустая последняя колонка — не гадаем (C-1)
|
||||||
|
return self._out("failed", error="не удалось прочитать колонки доски")
|
||||||
|
moved = [k for k, sid in self.sprint_issues(sp["id"]) if sid is None or sid not in done_ids]
|
||||||
|
first = True
|
||||||
|
for i in range(0, len(moved), 50):
|
||||||
|
if not first:
|
||||||
|
self.gap("ensure_sprint_state")
|
||||||
|
first = False
|
||||||
|
path = "/sprint/%s/issue" % target["id"] if target else "/backlog/issue"
|
||||||
|
self.jira.post(AGILE + path, {"issues": moved[i:i + 50]})
|
||||||
|
if not first:
|
||||||
|
self.gap("ensure_sprint_state")
|
||||||
|
self.jira.post(AGILE + "/sprint/%s" % sp["id"], {"state": "closed"})
|
||||||
|
return self._out("done", {"sprint_id": sp["id"], "moved": moved})
|
||||||
|
|
||||||
|
# --- ensure_rank -----------------------------------------------------------
|
||||||
|
def do_ensure_rank(self, p: dict, cmd: dict) -> dict:
|
||||||
|
"""Положение задачи относительно другой: `before` — выше, `after` — ниже (ровно одно). Ранги читаются одним
|
||||||
|
поиском; задача уже на месте — ничего не пишем. Ошибка в entries ответа PUT — failed (R-73)."""
|
||||||
|
before, after = p.get("before"), p.get("after")
|
||||||
|
if bool(before) == bool(after):
|
||||||
|
return self._out("failed", error="нужно ровно одно из before, after")
|
||||||
|
key = p["key"]
|
||||||
|
other = before or after
|
||||||
|
if other == key:
|
||||||
|
return self._out("failed", error="задача не может стоять выше или ниже самой себя")
|
||||||
|
rid = self.jira_fields().get("rank")
|
||||||
|
if not rid:
|
||||||
|
return self._out("failed", error="поле ранга не найдено")
|
||||||
|
page = self.jira.get(API + "/search", jql="key in (%s,%s)" % (key, other), fields=rid)
|
||||||
|
ranks = {i.get("key"): (i.get("fields") or {}).get(rid) for i in page.get("issues") or []}
|
||||||
|
mine, theirs = ranks.get(key), ranks.get(other)
|
||||||
|
if isinstance(mine, str) and isinstance(theirs, str):
|
||||||
|
if (before and mine < theirs) or (after and mine > theirs):
|
||||||
|
return self._out("done", {"changed": False})
|
||||||
|
body = {"issues": [key], "rankBeforeIssue" if before else "rankAfterIssue": other}
|
||||||
|
digits = re.search(r"(\d+)$", rid)
|
||||||
|
if digits: # id без цифрового хвоста — без rankCustomFieldId
|
||||||
|
body["rankCustomFieldId"] = int(digits.group(1))
|
||||||
|
resp = self.jira.put(AGILE + "/issue/rank", body)
|
||||||
|
for entry in (resp or {}).get("entries") or []: # 207: ошибка по задаче — в entries, не в коде ответа
|
||||||
|
status = entry.get("status")
|
||||||
|
if status is not None and not 200 <= int(status) < 300:
|
||||||
|
errors = "; ".join(str(x) for x in entry.get("errors") or []) or "статус %s" % status
|
||||||
|
return self._out("failed", error="Jira не переранжировала %s: %s" % (key, errors))
|
||||||
|
return self._out("done", {"changed": True})
|
||||||
|
|
||||||
# --- ensure_attachment -----------------------------------------------------
|
# --- ensure_attachment -----------------------------------------------------
|
||||||
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
|
def hub_file(self, sha: str, size: int) -> Optional[bytes]:
|
||||||
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
|
"""Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором,
|
||||||
@ -1067,7 +1332,8 @@ class Executor:
|
|||||||
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
|
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
|
||||||
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||||||
extra.append(ep["field_id"])
|
extra.append(ep["field_id"])
|
||||||
return ",".join(self.STATE_FIELDS + extra)
|
extra += [v for v in self.jira_fields().values() if v]
|
||||||
|
return ",".join(dict.fromkeys(self.STATE_FIELDS + extra))
|
||||||
|
|
||||||
def search_all(self, jql: str) -> List[dict]:
|
def search_all(self, jql: str) -> List[dict]:
|
||||||
issues, start = [], 0
|
issues, start = [], 0
|
||||||
@ -1106,15 +1372,49 @@ class Executor:
|
|||||||
def name(obj):
|
def name(obj):
|
||||||
return (obj or {}).get("name")
|
return (obj or {}).get("name")
|
||||||
|
|
||||||
|
jf = self.jira_fields()
|
||||||
ep = self.s.get("epic_link") or {}
|
ep = self.s.get("epic_link") or {}
|
||||||
epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
|
epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
|
||||||
return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
|
st = {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
|
||||||
"description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
|
"description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
|
||||||
"assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
|
"assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
|
||||||
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
|
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
|
||||||
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
|
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
|
||||||
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
|
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
|
||||||
"created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)}
|
"created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)}
|
||||||
|
if jf["rank"]: # поле не найдено — ключа нет, а не null (R-64)
|
||||||
|
v = f.get(jf["rank"])
|
||||||
|
st["rank"] = v if isinstance(v, str) else None
|
||||||
|
if jf["story_points"]:
|
||||||
|
v = f.get(jf["story_points"])
|
||||||
|
st["story_points"] = v if isinstance(v, (int, float)) and not isinstance(v, bool) else None
|
||||||
|
if jf["sprint"]:
|
||||||
|
st["sprint_ids"] = self.sprint_ids_of(raw["key"], f.get(jf["sprint"]))
|
||||||
|
return st
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sprint_ids_of(key: str, value: Any) -> List[int]:
|
||||||
|
"""id спринтов из поля спринтов: элементы — словари (id — число или строка цифр) или строки greenhopper
|
||||||
|
(«...[id=72,...]»). Не разобранный элемент — лог и пропуск; повторы убираются."""
|
||||||
|
out: List[int] = []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return out
|
||||||
|
for it in value:
|
||||||
|
sid = None
|
||||||
|
if isinstance(it, dict):
|
||||||
|
i = it.get("id")
|
||||||
|
if isinstance(i, int) and not isinstance(i, bool):
|
||||||
|
sid = i
|
||||||
|
elif isinstance(i, str) and i.isascii() and i.isdigit():
|
||||||
|
sid = int(i)
|
||||||
|
elif isinstance(it, str):
|
||||||
|
m = re.search(r"[\[,]id=(\d+)", it)
|
||||||
|
sid = int(m.group(1)) if m else None
|
||||||
|
if sid is None:
|
||||||
|
log(" ! %s: элемент поля спринта не разобран" % key)
|
||||||
|
elif sid not in out:
|
||||||
|
out.append(sid)
|
||||||
|
return out
|
||||||
|
|
||||||
def links_from(self, raw: dict) -> List[dict]:
|
def links_from(self, raw: dict) -> List[dict]:
|
||||||
key, out = raw["key"], []
|
key, out = raw["key"], []
|
||||||
@ -1227,13 +1527,14 @@ class Executor:
|
|||||||
log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t))
|
log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]:
|
def refresh_boards(self, p: dict) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]:
|
||||||
"""(scrum-доски для имён спринтов, типы запрошенных досок для результата или None).
|
"""(scrum-доски для имён спринтов, доски с типом scrum/kanban для данных досок, типы запрошенных досок для
|
||||||
Без boards в команде — хаб до проектов: доска из настроек, как v11."""
|
результата или None). Без boards в команде — хаб до проектов: доска из настроек, как v11."""
|
||||||
if "boards" not in p:
|
if "boards" not in p:
|
||||||
board = self.s.get("board_id")
|
board = self.s.get("board_id")
|
||||||
return ([board] if board else []), None
|
return ([board] if board else []), [], None
|
||||||
scrum: List[Any] = []
|
scrum: List[Any] = []
|
||||||
|
typed: List[Tuple[Any, str]] = []
|
||||||
types: Dict[str, Optional[str]] = {}
|
types: Dict[str, Optional[str]] = {}
|
||||||
seen = set()
|
seen = set()
|
||||||
for b in p.get("boards") or []:
|
for b in p.get("boards") or []:
|
||||||
@ -1248,7 +1549,123 @@ class Executor:
|
|||||||
t = types[str(bid)] = self.board_type(bid)
|
t = types[str(bid)] = self.board_type(bid)
|
||||||
if t == "scrum":
|
if t == "scrum":
|
||||||
scrum.append(bid)
|
scrum.append(bid)
|
||||||
return scrum, (types or None)
|
if t in ("scrum", "kanban"):
|
||||||
|
typed.append((bid, t))
|
||||||
|
return scrum, typed, (types or None)
|
||||||
|
|
||||||
|
# --- данные досок в первой пачке refresh (v15) -------------------------------------------------------------
|
||||||
|
def status_names(self) -> Dict[str, str]:
|
||||||
|
"""id статуса → имя (GET /status); кэш на экземпляр, ошибка Jira наружу и не кэшируется."""
|
||||||
|
if self._status_names is None:
|
||||||
|
self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")}
|
||||||
|
return self._status_names
|
||||||
|
|
||||||
|
def board_config_columns(self, board: Any) -> Optional[List[dict]]:
|
||||||
|
"""Колонки доски как их отдаёт Jira (GET /board/{id}/configuration): [{name, statuses: [{id}]}];
|
||||||
|
ошибка Jira — лог и None."""
|
||||||
|
try:
|
||||||
|
cfg = self.jira.get(AGILE + "/board/%s/configuration" % board)
|
||||||
|
except HttpError as e:
|
||||||
|
log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code))
|
||||||
|
return None
|
||||||
|
return (cfg.get("columnConfig") or {}).get("columns") or []
|
||||||
|
|
||||||
|
def board_columns(self, board: Any) -> Optional[List[dict]]:
|
||||||
|
"""Колонки доски для хаба: [{name, statuses: [имя]}]. Неизвестный id статуса: один раз за команду кэш имён
|
||||||
|
перечитывается (статус могли добавить после заполнения кэша), всё ещё неизвестен — пропуск с записью в лог (C-1);
|
||||||
|
колонка без строкового имени — пропуск с логом (M-3); ошибка Jira — лог и None."""
|
||||||
|
raw = self.board_config_columns(board)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
names = self.status_names()
|
||||||
|
out = []
|
||||||
|
for col in raw:
|
||||||
|
cname = col.get("name")
|
||||||
|
if not isinstance(cname, str):
|
||||||
|
log("доска %s: колонка %r пропущена — нет строкового имени" % (board, cname))
|
||||||
|
continue
|
||||||
|
statuses = []
|
||||||
|
for st in col.get("statuses") or []:
|
||||||
|
sid = str(st.get("id"))
|
||||||
|
if sid not in names and not self._status_reread:
|
||||||
|
self._status_reread, self._status_names = True, None
|
||||||
|
names = self.status_names()
|
||||||
|
if sid in names:
|
||||||
|
statuses.append(names[sid])
|
||||||
|
else:
|
||||||
|
log("доска %s: статус %s колонки «%s» не найден" % (board, sid, cname))
|
||||||
|
out.append({"name": cname, "statuses": statuses})
|
||||||
|
except HttpError as e:
|
||||||
|
log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code))
|
||||||
|
return None
|
||||||
|
return out
|
||||||
|
|
||||||
|
def kanban_backlog(self, board: Any) -> Optional[bool]:
|
||||||
|
"""Включён ли бэклог канбан-доски: ответ — True, 400 — False, иная ошибка — None с записью в лог."""
|
||||||
|
try:
|
||||||
|
self.jira.get(AGILE + "/board/%s/backlog" % board, maxResults=0)
|
||||||
|
except HttpError as e:
|
||||||
|
if e.code == 400:
|
||||||
|
return False
|
||||||
|
log("refresh: доска %s — бэклог не прочитан (HTTP %d)" % (board, e.code))
|
||||||
|
return None
|
||||||
|
return True
|
||||||
|
|
||||||
|
def all_sprints(self, board: Any) -> List[dict]:
|
||||||
|
"""Все спринты доски любых состояний (GET /board/{id}/sprint без state, по 50); кэш на команду.
|
||||||
|
Заодно заполняет self._sprints именами активных и будущих, чтобы sprints() не спрашивал Jira повторно."""
|
||||||
|
key = str(board)
|
||||||
|
if key not in self._all_sprints:
|
||||||
|
out, start = [], 0
|
||||||
|
while True:
|
||||||
|
page = self.jira.get(AGILE + "/board/%s/sprint" % board, startAt=start, maxResults=50)
|
||||||
|
vals = page.get("values") or []
|
||||||
|
out += vals
|
||||||
|
if page.get("isLast", True) or not vals:
|
||||||
|
break
|
||||||
|
start += len(vals)
|
||||||
|
self._all_sprints[key] = out
|
||||||
|
self._sprints[key] = {sp["name"]: sp for sp in out
|
||||||
|
if sp.get("name") and str(sp.get("state")).lower() in ("active", "future")}
|
||||||
|
return self._all_sprints[key]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def sprint_state(sp: dict) -> Optional[dict]:
|
||||||
|
"""Спринт для хаба; id не целое > 0, name не непустая строка или состояние вне active/future/closed — None
|
||||||
|
(R-72, M-3: как check_boards хаба); нестроковые даты и goal — None."""
|
||||||
|
state = sp.get("state")
|
||||||
|
state = state.lower() if isinstance(state, str) else None
|
||||||
|
sid, name = sp.get("id"), sp.get("name")
|
||||||
|
if (not isinstance(sid, int) or isinstance(sid, bool) or sid <= 0 or not isinstance(name, str) or not name
|
||||||
|
or state not in ("active", "future", "closed")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def text(v: Any) -> Optional[str]:
|
||||||
|
return v if isinstance(v, str) else None
|
||||||
|
return {"id": sid, "name": name, "state": state, "start_date": text(sp.get("startDate")),
|
||||||
|
"end_date": text(sp.get("endDate")), "complete_date": text(sp.get("completeDate")),
|
||||||
|
"goal": text(sp.get("goal"))}
|
||||||
|
|
||||||
|
def board_state(self, board: Any, kind: str) -> Optional[dict]:
|
||||||
|
"""Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null."""
|
||||||
|
columns = self.board_columns(board)
|
||||||
|
backlog = self.kanban_backlog(board) if kind == "kanban" and columns is not None else None # M-1
|
||||||
|
sprints: List[dict] = []
|
||||||
|
if kind == "scrum":
|
||||||
|
try:
|
||||||
|
raw = self.all_sprints(board)
|
||||||
|
except HttpError as e:
|
||||||
|
log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" % (board, e.code))
|
||||||
|
return None
|
||||||
|
for sp in raw:
|
||||||
|
st = self.sprint_state(sp)
|
||||||
|
if st is None:
|
||||||
|
log("refresh: доска %s — спринт %r пропущен: некорректные id/name или состояние не active/future/closed"
|
||||||
|
% (board, sp.get("id")))
|
||||||
|
else:
|
||||||
|
sprints.append(st)
|
||||||
|
return {"id": board, "type": kind, "columns": columns, "kanban_backlog": backlog, "sprints": sprints}
|
||||||
|
|
||||||
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
|
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
|
||||||
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
|
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
|
||||||
@ -1286,16 +1703,26 @@ class Executor:
|
|||||||
break
|
break
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
def send_empty_batch(self, cmd: dict, boards: List[dict]) -> None:
|
||||||
|
self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": [], "links": [],
|
||||||
|
"neighbor_keys": [], "page": 1, "last_page": True, "boards": boards})
|
||||||
|
|
||||||
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
||||||
missing: List[str] = []
|
missing: List[str] = []
|
||||||
errors: List[HttpError] = []
|
errors: List[HttpError] = []
|
||||||
scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
|
scrum, typed, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
|
||||||
|
boards = None
|
||||||
|
if "boards" in p:
|
||||||
|
boards = [b for b in (self.board_state(bid, kind) for bid, kind in typed) if b is not None]
|
||||||
if p.get("jql"):
|
if p.get("jql"):
|
||||||
raws = self.search_all(p["jql"])
|
raws = self.search_all(p["jql"])
|
||||||
else:
|
else:
|
||||||
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
|
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
|
||||||
if not keys:
|
if not keys:
|
||||||
empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0}
|
empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0}
|
||||||
|
if boards is not None: # данные досок уходят пустой пачкой (R-66)
|
||||||
|
self.send_empty_batch(cmd, boards)
|
||||||
|
empty["pages"] = 1
|
||||||
if types is not None:
|
if types is not None:
|
||||||
empty["boards"] = types
|
empty["boards"] = types
|
||||||
return self._out("done", empty)
|
return self._out("done", empty)
|
||||||
@ -1316,6 +1743,11 @@ class Executor:
|
|||||||
for i in range(0, len(absent), 100):
|
for i in range(0, len(absent), 100):
|
||||||
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
|
||||||
everything = raws + neighbors
|
everything = raws + neighbors
|
||||||
|
if everything:
|
||||||
|
jf = self.jira_fields()
|
||||||
|
for k, what, name in (("rank", "ранга", "rank"), ("sprint", "спринта", "sprint_ids"), ("story_points", "оценки", "story_points")):
|
||||||
|
if not jf[k]:
|
||||||
|
log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (what, name))
|
||||||
types = dict(types or {})
|
types = dict(types or {})
|
||||||
names = self.sprint_names([r["key"] for r in everything], scrum, types)
|
names = self.sprint_names([r["key"] for r in everything], scrum, types)
|
||||||
issues, links, seen = [], [], set()
|
issues, links, seen = [], [], set()
|
||||||
@ -1337,10 +1769,16 @@ class Executor:
|
|||||||
chunk = issues[i:i + 100]
|
chunk = issues[i:i + 100]
|
||||||
chunk_keys = {x["key"] for x in chunk}
|
chunk_keys = {x["key"] for x in chunk}
|
||||||
pages += 1
|
pages += 1
|
||||||
self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk,
|
state = {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk,
|
||||||
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
|
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
|
||||||
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
||||||
"page": pages, "last_page": i + 100 >= len(issues)})
|
"page": pages, "last_page": i + 100 >= len(issues)}
|
||||||
|
if pages == 1 and boards is not None:
|
||||||
|
state["boards"] = boards
|
||||||
|
self._hub(self.hub.state, state)
|
||||||
|
if not pages and boards is not None: # выборка пуста, а доски прочитаны (R-66)
|
||||||
|
self.send_empty_batch(cmd, boards)
|
||||||
|
pages = 1
|
||||||
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||||||
history = self.refresh_history(p, cmd, everything)
|
history = self.refresh_history(p, cmd, everything)
|
||||||
if history is not None:
|
if history is not None:
|
||||||
@ -1392,9 +1830,8 @@ class Executor:
|
|||||||
names: Dict[str, Any] = {}
|
names: Dict[str, Any] = {}
|
||||||
|
|
||||||
def field_id_of(name: str) -> Optional[str]:
|
def field_id_of(name: str) -> Optional[str]:
|
||||||
if "map" not in names: # один GET /field за refresh, только по нужде
|
if "map" not in names: # по списку полей команды — GET /field один раз (field_list)
|
||||||
names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or []
|
names["map"] = {f.get("name"): f.get("id") for f in self.field_list()}
|
||||||
if isinstance(f, dict)}
|
|
||||||
return names["map"].get(name)
|
return names["map"].get(name)
|
||||||
|
|
||||||
items: List[dict] = []
|
items: List[dict] = []
|
||||||
|
|||||||
@ -9,7 +9,8 @@ if SRC not in sys.path:
|
|||||||
import agent # noqa: E402
|
import agent # noqa: E402
|
||||||
|
|
||||||
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
|
ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0,
|
||||||
"ensure_sprint": 0, "ensure_attachment": 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 = {
|
SETTINGS = {
|
||||||
"base_jql": "project = AURORA AND labels = AWPCA",
|
"base_jql": "project = AURORA AND labels = AWPCA",
|
||||||
|
|||||||
@ -21,7 +21,13 @@ WORKFLOW = { # статус → [(имя перехода, целевой ст
|
|||||||
}
|
}
|
||||||
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
|
LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"},
|
||||||
{"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):
|
def parse_multipart(raw, content_type):
|
||||||
@ -71,7 +77,16 @@ class FakeJira:
|
|||||||
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
|
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.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
|
||||||
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
|
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_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.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется
|
||||||
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
|
||||||
self.id_seq = 10000
|
self.id_seq = 10000
|
||||||
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой
|
||||||
@ -100,7 +115,13 @@ class FakeJira:
|
|||||||
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
|
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
|
||||||
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
|
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
|
||||||
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
|
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
|
||||||
{"id": "customfield_10104", "name": "Sprint", "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
|
{"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
fake = self
|
fake = self
|
||||||
@ -140,7 +161,7 @@ class FakeJira:
|
|||||||
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний",
|
||||||
"assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [],
|
"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",
|
"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
|
self.id_seq += 1
|
||||||
base["id"] = str(self.id_seq)
|
base["id"] = str(self.id_seq)
|
||||||
base.update({c: None for c in CUSTOM})
|
base.update({c: None for c in CUSTOM})
|
||||||
@ -202,6 +223,7 @@ class FakeJira:
|
|||||||
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
|
"duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]},
|
||||||
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
|
"created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)}
|
||||||
out.update({c: f.get(c) for c in CUSTOM})
|
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:
|
if fields is None or "comment" in fields:
|
||||||
lst = self.comments.get(key, [])
|
lst = self.comments.get(key, [])
|
||||||
cap = self.search_comment_cap if fields is not None else None
|
cap = self.search_comment_cap if fields is not None else None
|
||||||
@ -212,6 +234,22 @@ class FakeJira:
|
|||||||
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])]
|
||||||
return {"key": key, "id": f["id"], "fields": out}
|
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):
|
def links_of(self, key):
|
||||||
out = []
|
out = []
|
||||||
for l in self.links:
|
for l in self.links:
|
||||||
@ -259,6 +297,9 @@ class FakeJira:
|
|||||||
if self.fail_next:
|
if self.fail_next:
|
||||||
code, headers = self.fail_next.pop(0)
|
code, headers = self.fail_next.pop(0)
|
||||||
return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers)
|
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:
|
try:
|
||||||
code, payload = self.route(method, url.path, q, body)
|
code, payload = self.route(method, url.path, q, body)
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
@ -445,6 +486,22 @@ class FakeJira:
|
|||||||
if board not in self.boards:
|
if board not in self.boards:
|
||||||
return 404, {"errorMessages": ["Board does not exist"]}
|
return 404, {"errorMessages": ["Board does not exist"]}
|
||||||
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
|
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)
|
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
|
||||||
if m:
|
if m:
|
||||||
board = int(m.group(1))
|
board = int(m.group(1))
|
||||||
@ -452,27 +509,54 @@ class FakeJira:
|
|||||||
return 404, {"errorMessages": ["Board does not exist"]}
|
return 404, {"errorMessages": ["Board does not exist"]}
|
||||||
if self.boards.get(board) == "kanban":
|
if self.boards.get(board) == "kanban":
|
||||||
return 400, {"errorMessages": ["The board does not support sprints"]}
|
return 400, {"errorMessages": ["The board does not support sprints"]}
|
||||||
states = (q.get("state") or "active,future").split(",")
|
states = q["state"].split(",") if q.get("state") else None # без state — спринты всех состояний
|
||||||
vals = [s for s in self.board_sprints.get(board, self.sprints) if s["state"] in states]
|
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))
|
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||||||
return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)}
|
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)
|
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)
|
||||||
if m:
|
if m:
|
||||||
sp = next(s for s in self.all_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":
|
if method == "GET":
|
||||||
members = self.sprint_members.get(sp["id"])
|
members = self.sprint_members.get(sp["id"])
|
||||||
keys = [k for k in self.search_keys(q.get("jql") or "key in ()")
|
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"])]
|
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))
|
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
|
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"]:
|
for k in body["issues"]:
|
||||||
self.issues[k]["sprint"] = sp["name"]
|
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
|
return 204, None
|
||||||
if p == "/rest/agile/1.0/backlog/issue" and method == "POST":
|
if p == "/rest/agile/1.0/backlog/issue" and method == "POST":
|
||||||
for k in body["issues"]:
|
for k in body["issues"]:
|
||||||
self.issues[k]["sprint"] = None
|
self.issues[k]["sprint"] = None
|
||||||
return 204, 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/([^/]+)$", p)
|
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p)
|
||||||
if m:
|
if m:
|
||||||
name = self.issues[m.group(1)]["sprint"]
|
name = self.issues[m.group(1)]["sprint"]
|
||||||
@ -482,6 +566,32 @@ class FakeJira:
|
|||||||
return 200, {"key": m.group(1), "fields": {"sprint": sp}}
|
return 200, {"key": m.group(1), "fields": {"sprint": sp}}
|
||||||
return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]}
|
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):
|
def all_sprints(self):
|
||||||
out = list(self.sprints)
|
out = list(self.sprints)
|
||||||
for vals in self.board_sprints.values():
|
for vals in self.board_sprints.values():
|
||||||
|
|||||||
@ -259,6 +259,40 @@ class ExecutorFieldsTest(unittest.TestCase):
|
|||||||
out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3")
|
out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3")
|
||||||
self.assertEqual(out["result"], {"changed": []})
|
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(), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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: Ранжирование недоступно"))
|
||||||
@ -197,7 +197,7 @@ class FolderIntegrationTest(unittest.TestCase):
|
|||||||
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token)
|
||||||
|
|
||||||
def test_version(self):
|
def test_version(self):
|
||||||
self.assertEqual(agent.AGENT_VERSION, 14)
|
self.assertEqual(agent.AGENT_VERSION, 15)
|
||||||
|
|
||||||
def test_runner_through_folder(self):
|
def test_runner_through_folder(self):
|
||||||
self.fake.add("AURORA-1", duedate="2026-10-20")
|
self.fake.add("AURORA-1", duedate="2026-10-20")
|
||||||
|
|||||||
@ -310,7 +310,7 @@ class RefreshHistoryTest(Base):
|
|||||||
self.assertEqual(self.changelog_queries(), [])
|
self.assertEqual(self.changelog_queries(), [])
|
||||||
|
|
||||||
def test_version(self):
|
def test_version(self):
|
||||||
self.assertEqual(agent.AGENT_VERSION, 14)
|
self.assertEqual(agent.AGENT_VERSION, 15)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -5,6 +5,11 @@ from fake_hub import FakeHub
|
|||||||
from fake_jira import FakeJira
|
from fake_jira import FakeJira
|
||||||
|
|
||||||
|
|
||||||
|
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
|
||||||
|
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
|
||||||
|
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
|
||||||
|
|
||||||
|
|
||||||
class RefreshTest(unittest.TestCase):
|
class RefreshTest(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.fake = FakeJira()
|
self.fake = FakeJira()
|
||||||
@ -258,6 +263,12 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertEqual(out["status"], "done")
|
self.assertEqual(out["status"], "done")
|
||||||
self.assertEqual(out["result"]["missing"], ["GONE-1"])
|
self.assertEqual(out["result"]["missing"], ["GONE-1"])
|
||||||
|
|
||||||
|
def test_agent_version_15(self):
|
||||||
|
self.assertEqual(agent.AGENT_VERSION, 15)
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||||
|
self.assertEqual(self.hub.states[0]["agent_version"], 15)
|
||||||
|
|
||||||
def test_input_keys_deduplicated(self):
|
def test_input_keys_deduplicated(self):
|
||||||
self.fake.add("AURORA-1")
|
self.fake.add("AURORA-1")
|
||||||
out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}})
|
out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}})
|
||||||
@ -336,6 +347,7 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertFalse(out["retryable"])
|
self.assertFalse(out["retryable"])
|
||||||
|
|
||||||
def test_jira_error_has_no_peer(self):
|
def test_jira_error_has_no_peer(self):
|
||||||
|
self.ex.jira_fields()
|
||||||
self.fake.fail_next.append((500, {}))
|
self.fake.fail_next.append((500, {}))
|
||||||
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
|
out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}})
|
||||||
self.assertTrue(out["retryable"])
|
self.assertTrue(out["retryable"])
|
||||||
@ -354,6 +366,46 @@ class RefreshTest(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
agent.log = orig
|
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): # сценарий «Две доски, одна новая»
|
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
|
||||||
self.fake.boards[5120] = "kanban"
|
self.fake.boards[5120] = "kanban"
|
||||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||||
@ -389,6 +441,8 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertNotIn("boards", out["result"])
|
self.assertNotIn("boards", out["result"])
|
||||||
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
|
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.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):
|
def test_known_boards_no_type_requests(self):
|
||||||
self.fake.add("AURORA-1", sprint="Спринт 2")
|
self.fake.add("AURORA-1", sprint="Спринт 2")
|
||||||
@ -430,7 +484,11 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.fake.boards[5120] = "kanban"
|
self.fake.boards[5120] = "kanban"
|
||||||
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
|
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
|
||||||
self.assertEqual(out["status"], "done")
|
self.assertEqual(out["status"], "done")
|
||||||
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
|
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):
|
def test_last_found_sprint_wins_across_boards(self):
|
||||||
self.fake.boards[5120] = "scrum"
|
self.fake.boards[5120] = "scrum"
|
||||||
@ -466,6 +524,181 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertEqual(out["status"], "done", out)
|
self.assertEqual(out["status"], "done", out)
|
||||||
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user