Агент v15: спринты, ранг, оценки (#49) #50

Merged
aep merged 19 commits from feat/49-v15-sprints into main 2026-10-25 13:56:49 +03:00
14 changed files with 1880 additions and 34 deletions

View File

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

View File

@ -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`.
## Выкатка новой версии ## Выкатка новой версии

View File

@ -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`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.

View 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).

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-24

View 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), не по смыслу |

View File

@ -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] = []

View File

@ -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",

View File

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

View File

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

View 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: Ранжирование недоступно"))

View File

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

View File

@ -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__":

View File

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