Агент v12: доски проектов из параметров команд (#35) #36

Merged
aep merged 5 commits from feat/35-projects into main 2026-10-19 19:43:29 +03:00
11 changed files with 813 additions and 48 deletions

View File

@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-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 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `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 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` |
| Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/<id>.json` → реле хаба → `resp/<id>.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-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` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `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` |

View File

@ -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 — `../jira-hub/openspec/changes/bug-and-attachments/` (агент v11: тип «Ошибка», `ensure_attachment`, устойчивое дочитывание комментариев; решения агента — `openspec/changes/agent-v11-bug-attachments/design.md`), план — `docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md`. Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`.
План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. План и спека живут в `jira-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`.
## Выкатка новой версии ## Выкатка новой версии

View File

@ -24,8 +24,10 @@
3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. 3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки.
Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira. Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira.
Остальные настройки (id полей, типы задач, карта переходов, типы связей, доска, темп) агент получает от хаба при Остальные настройки (id полей, типы задач, карта переходов, типы связей, темп) агент получает от хаба при
старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. Доски Jira (с v12) приходят в
параметрах команд: `ensure_sprint` — `board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд
старого хаба.
## Проверка ## Проверка
@ -170,9 +172,9 @@ python3 agent.py prune
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira |
| `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется |
| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет | | `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок |
Правила работы: Правила работы:
- **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу.
@ -190,7 +192,7 @@ python3 agent.py prune
`finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики
суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке
по сигналу команду подхватит восстановление. по сигналу команду подхватит восстановление.
- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 11) отправляется в каждом `claim`, в каждом отчёте - **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `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` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в
@ -198,6 +200,9 @@ python3 agent.py prune
Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации
(тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30). (тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30).
Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`. Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`.
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
выкатывается только после v12 (design хаба `projects`, решение 14).
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды
при этом выдаются по-прежнему. при этом выдаются по-прежнему.
@ -234,8 +239,11 @@ python3 agent.py prune
следующем `claim` — агент выполнит её через проверку. следующем `claim` — агент выполнит её через проверку.
2. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` 2. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url`
должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают.
3. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по 3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по
этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного
типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если
scrum-доска стала канбаном, `refresh` пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по
другой причине (удалена, нет доступа), `refresh` завершается `failed` с номером доски и текстом Jira (R-32).
4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в 4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в
`result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`).
Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50).

View File

@ -0,0 +1,487 @@
# projects — агент v12: доски проектов из параметров команд — план
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент v12 берёт доски Jira из параметров команд: `ensure_sprint` — из `board_id` команды, `refresh` — из
`boards` (`[{id, type}]`); для досок с `type = null` определяет тип (`GET /rest/agile/1.0/board/{id}`) и возвращает его
в результате `boards`, спринты задач собирает со всех scrum-досок; без `board_id`/`boards` работает по
`settings.board_id`, как v11.
**Architecture:** всё в `src/agent.py`, класс `Executor`. Кэш спринтов `_sprints` из одного словаря становится словарём
«доска → (имя → спринт)» и по-прежнему сбрасывается в `Executor.run` на каждую команду (а при смене настроек `Runner`
пересоздаёт `Executor`). `sprints(board)` читает активные и будущие спринты одной доски; `do_ensure_sprint` берёт доску
`p["board_id"]`, иначе `settings.board_id`. В `do_refresh` первым шагом `refresh_boards(p)` определяет типы досок с
`type = null` (`board_type(board)`, ошибка HTTP — `None` и строка в логе) и отдаёт список scrum-досок и словарь для
результата; `sprint_names(keys, boards)` обходит доски по порядку, последнее найденное имя побеждает.
**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках
`tests/fake_jira.py`, `tests/fake_hub.py`.
**Spec:** change хаба `projects` (aep/jira-hub#61), пока в worktree хаба:
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/changes/projects/` —
`specs/agent/spec.md` (ADDED «Доски проектов» — основное, MODIFIED «Состав и настройки агента»),
`specs/commands/spec.md` (таблица команд: `ensure_sprint.board_id`, `refresh.boards`, результат `boards`),
`design.md` (решения 4, 7, 14, 15; «Decisions made without discussion» № 6, 7, 14). Действующая спека агента —
`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/specs/agent/spec.md`. Решения агентской
части — `openspec/changes/agent-v12-project-boards/design.md` (создаётся в Task 3).
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/projects`, ветка `feat/35-projects`
(aep/jira-agent#35). Все команды — из корня worktree; основные checkout'ы `jira-agent` и `jira-hub` не трогать.
## Global Constraints
- Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+
(`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`. Для кортежей в аннотациях — `typing.Tuple`.
- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь
набор (до плана — 262 теста; набор идёт ≈2 минуты, прогон всего набора — в фоне).
- `AGENT_VERSION` SHALL быть 12.
- `ensure_sprint` SHALL брать доску из `board_id` команды; нет `board_id` (или он `null`) — из `settings.board_id`, как v11.
- `refresh` SHALL брать доски из `boards` (`[{id, type}]`, `type` — `"scrum"`, `"kanban"` или `null`). Нет ключа
`boards` (хаб до проектов) — одна доска `settings.board_id`, как v11, результат без `boards`.
- Для каждой доски с `type = null` — ровно один `GET /rest/agile/1.0/board/{id}`; тип из ответа (`type`) — `"scrum"` или
`"kanban"`, иначе `null`; ошибка запроса — `null`, строка в логе с номером доски и кодом ответа, `refresh` продолжается.
- Результат `refresh` — `boards: {"<id>": "scrum" | "kanban" | null}` только для досок, чей тип агент запрашивал; ключ —
строка.
- Имена спринтов — со всех досок типа `scrum` (известного или только что определённого) в порядке `boards`; `kanban` и
`null` пропускаются; задача в спринтах двух досок получает последнее найденное имя (как в v11).
- `ensure_sprint` на доске без спринтов (Jira ответила ошибкой на список спринтов) — `failed` с текстом Jira.
- Коммиты на русском с `(#35)`, трейлер `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
## Review Focus
1. Хаб с проектами прислал `boards: []` (ни у одного проекта нет доски), а в его настройках остался `board_id = 4216`
(решение 14 хаба) — агент не читает спринты доски из настроек, спринты задач — `null` (Task 2,
`test_empty_boards_no_settings_fallback`).
2. `GET /board/{id}` ответил 5xx (а не 404) — тип `null`, строка в логе с кодом, `refresh` завершается `done`, состояние
уходит в хаб (Task 2, `test_board_type_5xx_logged_refresh_continues`).
3. Одна и та же доска с `type = null` дважды в `boards` — один запрос типа, один ключ в результате (Task 2,
`test_duplicate_null_board_requested_once`).
4. `refresh {keys: []}` с доской неизвестного типа — тип всё равно определяется и возвращается (иначе хвост пакета без
задач оставил бы тип неизвестным) (Task 2, `test_empty_keys_still_resolves_boards`).
5. Доска, известная хабу как `scrum`, перестала отдавать спринты (стала канбаном, удалена) — `refresh` `failed` с текстом
Jira, как v11, а не молчаливое обнуление спринтов в зеркале (Task 2, `test_scrum_board_sprints_error_fails_refresh`;
решение R-32).
---
### Task 1: Спринты по доске и `ensure_sprint` с `board_id` из команды
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `tests/fake_jira.py` — `FakeJira.__init__` (новые поля), маршруты Agile в `route` (стр. 352–380)
- Modify: `src/agent.py` — `Executor.__init__` и `Executor.run` (стр. 556–564, сброс `_sprints`), `Executor.sprints`
(стр. 838–851), `Executor.do_ensure_sprint` (стр. 853–873)
- Test: `tests/test_executor_links.py`
**Interfaces:**
- Consumes: —
- Produces:
- `FakeJira.boards: Dict[int, str]` — доска → тип, по умолчанию `{4216: "scrum"}`. `GET /rest/agile/1.0/board/{id}`:
есть — 200 `{"id": id, "name": "Доска <id>", "type": <тип>}`, нет — 404 `{"errorMessages": ["Board does not exist"]}`.
`GET /rest/agile/1.0/board/{id}/sprint` у доски с типом `"kanban"` — 400
`{"errorMessages": ["The board does not support sprints"]}`.
- `FakeJira.board_sprints: Dict[int, List[dict]]` — спринты доски; доски нет в словаре — общий `self.sprints` (как
раньше, чтобы старые тесты не менялись).
- `FakeJira.sprint_members: Dict[int, List[str]]` — id спринта → ключи задач; для такого спринта
`GET /sprint/{id}/issue` отдаёт эти ключи (с фильтром по `jql` `key in (…)`) вместо сравнения по имени.
- `FakeJira.all_sprints() -> List[dict]` — `self.sprints` + все списки `board_sprints`; по нему ищут спринт
`/sprint/{id}/issue` и `GET /rest/agile/1.0/issue/{key}`.
- `Executor._sprints: Dict[str, Dict[str, dict]]` — `str(доска)` → имя → спринт; `{}` в `__init__` и в начале `run`.
- `Executor.sprints(self, board: Any) -> Dict[str, dict]` — активные и будущие спринты доски `board` (постранично, как
v11), с кэшем на команду; ошибку Jira не глотает.
- [ ] **Step 1: Фейк — доски, спринты по доске, участники спринта**
Добавить в `FakeJira` поля и маршрут `GET /rest/agile/1.0/board/{id}` из Interfaces (регулярка `^/rest/agile/1\.0/board/(\d+)$`
не пересекается с `…/sprint`), 400 для канбан-доски в маршруте `/board/{id}/sprint`, список спринтов по `board_sprints`,
`all_sprints()` в маршрутах `/sprint/{id}/issue` и `/agile/1.0/issue/{key}`, `sprint_members` в `GET /sprint/{id}/issue`.
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
Expected: PASS (фейк по умолчанию ведёт себя как раньше).
- [ ] **Step 2: Падающие тесты**
В `tests/test_executor_links.py`, после `test_sprint_without_board`:
```python
def test_sprint_board_from_params(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
paths = [r[1] for r in self.fake.requests]
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
self.ex.s["board_id"] = None
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
def test_sprint_unknown_name_names_param_board(self):
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("5120", out["error"])
self.assertIn("Спринт K", out["error"])
def test_sprint_kanban_board_failed_with_jira_text(self):
self.fake.boards[5120] = "kanban"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertIn("does not support sprints", out["error"])
self.assertFalse(out["retryable"])
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
def test_sprint_backlog_ignores_board(self):
self.fake.boards[5120] = "kanban"
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
```
`test_sprint_without_board` (без `board_id` в команде и в настройках) не меняется: ошибка по-прежнему содержит `board_id`.
Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v`
Expected: FAIL `test_sprint_board_from_params` (идёт на доску 4216: «нет спринта «Спринт K»»),
`test_sprint_board_from_params_without_settings` («board_id не задан…»), `test_sprint_unknown_name_names_param_board`
(в тексте 4216, а не 5120); `test_sprint_kanban_board_failed_with_jira_text` и `test_sprint_backlog_ignores_board` могут
пройти уже сейчас — это допустимо, они фиксируют поведение.
- [ ] **Step 3: Реализация**
- `Executor._sprints = {}` в `__init__` и в начале `run` (вместо `None`).
- `sprints(self, board: Any) -> Dict[str, dict]` — тело v11, доска из аргумента, кэш `self._sprints[str(board)]`;
docstring: «Имя → спринт (active и future) доски board».
- `do_ensure_sprint`: `board = p.get("board_id") or self.s.get("board_id")` после ветки `backlog`; нет доски —
`failed` «board_id нет ни в команде, ни в настройках хаба — спринты недоступны»; дальше как v11 с `self.sprints(board)`,
в тексте «нет спринта …» — номер `board`.
- `sprint_names` пока зовёт `self.sprints(self.s["board_id"])` (переделывается в Task 2).
Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v`
Expected: PASS.
- [ ] **Step 4: Весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 5: Commit**
```bash
git add tests/fake_jira.py tests/test_executor_links.py src/agent.py
git commit -m "feat(agent): ensure_sprint по доске из команды, кэш спринтов по доскам (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 2: `refresh` с досками проектов: тип доски, спринты со всех scrum-досок, результат `boards`
**Модель исполнителя:** sonnet.
**Files:**
- Modify: `src/agent.py` — импорт `Tuple` из `typing` (стр. 34), новые `Executor.board_type`, `Executor.refresh_boards`
(раздел `refresh`, перед `sprint_names`), `Executor.sprint_names` (стр. 1024–1043), `Executor.do_refresh`
(стр. 1045–1095)
- Test: `tests/test_refresh.py`
**Interfaces:**
- Consumes: Task 1 — `Executor.sprints(board)`, `FakeJira.boards`, `FakeJira.board_sprints`, `FakeJira.sprint_members`.
- Produces:
- `Executor.board_type(self, board: Any) -> Optional[str]` — `GET /rest/agile/1.0/board/{board}`; `type` из ответа,
если он `"scrum"` или `"kanban"` (точное совпадение), иначе `None` и в лог
`"refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)`; `HttpError` любого кода — `None` и в лог
`"refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code)`; `ConnectionError` не ловит.
- `Executor.refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]` — (scrum-доски в
порядке `boards` без повторов по `str(id)`, словарь для результата). Нет ключа `boards` — `([settings.board_id]` или
`[]`, `None)`. Есть — для каждой доски с `type is None` один `board_type`; словарь — запрошенные доски
(`str(id)` → тип), пустой словарь возвращается как `None`. Элемент без `id` пропускается.
- `Executor.sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]` — как v11, но по каждой доске
`boards` по порядку (`for board in boards: for name, sp in self.sprints(board).items(): …`); пустые `keys` или
`boards` — `{}`.
- `do_refresh`: первым действием `scrum, types = self.refresh_boards(p)` (до поиска и до раннего выхода по пустым
`keys`); `names = self.sprint_names(keys, scrum)`; если `types is not None` — `result["boards"] = types` (и в раннем
выходе по пустым `keys`).
- [ ] **Step 1: Падающие тесты**
В `tests/test_refresh.py` поменять вызов в `test_sprint_names_paginated` на
`self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])` и добавить:
```python
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
def refresh(self, **params):
return self.ex.run({"id": 5, "type": "refresh", "params": params})
def logged(self, fn):
logs, orig = [], agent.log
agent.log = logs.append
try:
return fn(), logs
finally:
agent.log = orig
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
self.fake.boards[5120] = "kanban"
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
paths = [r[1] for r in self.fake.requests]
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_board_not_found(self): # сценарий «Доска не найдена»
self.fake.add("AURORA-1")
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"9999": None})
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
self.assertEqual(len(self.hub.states), 1)
def test_new_scrum_board_sprints_read(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1", sprint="Спринт K")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL)
self.assertNotIn("boards", out["result"])
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_known_boards_no_type_requests(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertNotIn("boards", out["result"])
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_empty_boards_no_settings_fallback(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[])
self.assertEqual(out["status"], "done", out)
self.assertNotIn("boards", out["result"])
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
def test_board_type_5xx_logged_refresh_continues(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": None})
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
def test_board_other_type_is_null(self):
self.fake.boards[7000] = "simple"
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
self.assertEqual(out["result"]["boards"], {"7000": None})
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
def test_duplicate_null_board_requested_once(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
def test_empty_keys_still_resolves_boards(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
def test_last_found_sprint_wins_across_boards(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1")
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
self.refresh(jql=self.JQL, boards=both)
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
self.refresh(jql=self.JQL, boards=both[::-1])
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_sprints_error_fails_refresh(self): # R-32
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
self.fake.add("AURORA-1")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}])
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertEqual(self.hub.states, [])
```
`FakeJira.search_keys` на любую JQL без `key in` и без `reporter` отдаёт задачи с меткой `AWPCA` — `self.JQL` подходит.
В `test_board_type_5xx_logged_refresh_continues` 500 достаётся запросу типа доски потому, что `refresh_boards` — первый
вызов Jira в `do_refresh` (R-30).
Run: `/usr/bin/python3 -m unittest tests.test_refresh -v`
Expected: FAIL — `KeyError: 'boards'` в результате, `TypeError` у `test_sprint_names_paginated` (лишний аргумент) и
запросы к доске 4216 там, где их быть не должно; `test_old_hub_without_boards` проходит уже сейчас.
- [ ] **Step 2: Реализация**
`board_type`, `refresh_boards`, `sprint_names(keys, boards)` и правка `do_refresh` — по Interfaces. Комментарий R-6 у
`sprint_names` сохранить.
Run: `/usr/bin/python3 -m unittest tests.test_refresh tests.test_executor_links -v`
Expected: PASS.
- [ ] **Step 3: Весь набор**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK`.
- [ ] **Step 4: Commit**
```bash
git add src/agent.py tests/test_refresh.py
git commit -m "feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Версия 12, документация, change решений агента
**Модель исполнителя:** haiku (готовый текст).
**Files:**
- Modify: `src/agent.py:36` (`AGENT_VERSION`)
- Modify: `tests/test_folder.py:185-186` (`test_version`)
- Modify: `README.md` — абзац «Остальные настройки…» (стр. 27), строки `ensure_sprint` и `refresh` таблицы команд
(стр. 173, 175), пункт «Версия» (стр. 193–200), пункт 3 «Известных ограничений» (стр. 237–238)
- Modify: `ARCHITECTURE.md` — строка «Исполнитель» таблицы разделов (стр. 14)
- Modify: `CLAUDE.md` — раздел «Первая подсистема», строка «Последний change —»
- Create: `openspec/changes/agent-v12-project-boards/design.md` (через `openspec new change agent-v12-project-boards`)
**Interfaces:**
- Consumes: Task 1–2.
- Produces: `AGENT_VERSION = 12`.
- [ ] **Step 1: Падающий тест версии**
В `tests/test_folder.py`, `test_version`: `assertEqual(agent.AGENT_VERSION, 11)` → `assertEqual(agent.AGENT_VERSION, 12)`.
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: FAIL — `AssertionError: 11 != 12`.
- [ ] **Step 2: Версия**
В `src/agent.py` `AGENT_VERSION = 11` → `AGENT_VERSION = 12`.
Run: `/usr/bin/python3 -m unittest tests.test_folder -v`
Expected: PASS.
- [ ] **Step 3: README**
- Стр. 27: `(id полей, типы задач, карта переходов, типы связей, доска, темп)` → `(id полей, типы задач, карта переходов,
типы связей, темп)`, а после предложения дописать: «Доски Jira (с v12) приходят в параметрах команд: `ensure_sprint` —
`board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд старого хаба.»
- Строка `ensure_sprint`: `переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и
будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira`.
- Строка `refresh`: дописать в конец ячейки `; доски с неизвестным типом (`boards`, `type = null`) — запрос
`/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок`.
- Пункт «Версия»: `(сейчас 11)` → `(сейчас 12)`; после строки «Агент v10 на `ensure_attachment` …» дописать строкой того
же отступа:
```markdown
Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате
`refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами
выкатывается только после v12 (design хаба `projects`, решение 14).
```
- Пункт 3 ограничений: `**Спринт в `refresh` — только активные и будущие спринты доски.**` → `**Спринт в `refresh` —
только активные и будущие спринты scrum-досок.**`; после «У задач закрытых спринтов `sprint = null`.» дописать: «Задачи
канбан-досок и досок неизвестного типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по
списку `boards`. Если scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом
Jira (R-32).»
- [ ] **Step 4: ARCHITECTURE.md и CLAUDE.md**
- `ARCHITECTURE.md`, строка «Исполнитель», колонка «Ответственность» — дописать в конец: `; доски — из параметров команд
(`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной`.
- `CLAUDE.md`, строку `Последний change — …` заменить на:
```markdown
Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`.
```
- [ ] **Step 5: Change решений агента**
Run: `openspec new change agent-v12-project-boards`
Expected: создан каталог `openspec/changes/agent-v12-project-boards/`.
Создать `openspec/changes/agent-v12-project-boards/design.md`; таблица — те же строки R-30…R-35, что в разделе
«Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими
номерами, начиная с R-36:
```markdown
# agent-v12-project-boards — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35).
Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки
агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`).
## Decisions made without discussion
Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»;
для агента — № 6, 7, 14); здесь — только решения агентской части.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
<строки R-30…R-35 из плана>
```
- [ ] **Step 6: Весь набор и синтаксис 3.9**
Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
Expected: `OK` (262 + новые из Task 1–2).
Run: `/usr/bin/python3 -m unittest tests.test_py39 -v`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add src/agent.py tests/test_folder.py README.md ARCHITECTURE.md CLAUDE.md openspec/changes/agent-v12-project-boards
git commit -m "chore(agent): версия 12, README и решения агента для досок проектов (#35)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
## Decisions made without discussion
Нумерация рулингов агентской части — с R-30, чтобы не пересечься с рулингами исполнения хабовой части change.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` |
| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) |
| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба |
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
## После плана
- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #35 и на change `projects` хаба. Агент
сливается и публикуется первым (design хаба, решения 14 и 15).
- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на
main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status`
«обновление: ok v11 → v12». Затем — выкатка хаба (шаги 2–4 решения 14 хаба).

View File

@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-10-19
skip_specs: true

View File

@ -0,0 +1,20 @@
# agent-v12-project-boards — решения агента
Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35).
Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки
агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`).
## Decisions made without discussion
Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»;
для агента — № 6, 7, 14); здесь — только решения агентской части.
| Маркер | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` |
| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) |
| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба |
| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет |
| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе |
| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` |
| R-36 | 2026-10-19 | По находке I-1 финального ревью: если scrum-доска не отдала спринты в `refresh`, агент перепроверяет её тип; стала `kanban` — доска пропускается, в результате `boards: {"<id>": "kanban"}`, `refresh` `done`; иначе `failed` с текстом «доска <id>: …» (код и `retryable` прежние) | Смена доски на канбан лечится без правки базы; при прочих ошибках владелец видит, какую доску править (R-32 расширил зону поражения на все проекты) | Лишний `GET /board/{id}` при ошибке спринтов; типы досок, определённые в упавшем прогоне, не доходят до хаба — следующий `refresh` спросит снова |

View File

@ -31,9 +31,9 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional, Tuple
AGENT_VERSION = 11 AGENT_VERSION = 12
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__))
@ -557,11 +557,11 @@ class Executor:
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 = None self._sprints = {} # str(доска) → имя → спринт; на одну команду
def run(self, cmd: dict) -> dict: def run(self, cmd: dict) -> dict:
self.jira.reset_counters() self.jira.reset_counters()
self._sprints = None self._sprints = {}
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"))
@ -835,11 +835,10 @@ class Executor:
return self._out("done", {"created": True, "removed": removed}) return self._out("done", {"created": True, "removed": removed})
# --- ensure_sprint --------------------------------------------------------- # --- ensure_sprint ---------------------------------------------------------
def sprints(self) -> Dict[str, dict]: def sprints(self, board: Any) -> Dict[str, dict]:
"""Имя → спринт (active и future) доски board_id; без доски — пусто.""" """Имя → спринт (active и future) доски board; без доски — пусто."""
if self._sprints is None: if str(board) not in self._sprints:
out, start = {}, 0 out, start = {}, 0
board = self.s.get("board_id")
while board: while board:
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50) page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
for sp in page.get("values", []): for sp in page.get("values", []):
@ -847,8 +846,8 @@ class Executor:
if page.get("isLast", True) or not page.get("values"): if page.get("isLast", True) or not page.get("values"):
break break
start += len(page["values"]) start += len(page["values"])
self._sprints = out self._sprints[str(board)] = out
return self._sprints return self._sprints[str(board)]
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict: def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
key, want = p["key"], p["sprint"] key, want = p["key"], p["sprint"]
@ -860,14 +859,15 @@ class Executor:
return self._out("done", {"sprint": None, "changed": False}) return self._out("done", {"sprint": None, "changed": False})
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]}) self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
return self._out("done", {"sprint": None, "from": cur}) return self._out("done", {"sprint": None, "from": cur})
if not self.s.get("board_id"): board = p.get("board_id") or self.s.get("board_id") # без доски в команде — хаб до проектов (v11)
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") if not board:
return self._out("failed", error="board_id нет ни в команде, ни в настройках хаба — спринты недоступны")
if cur == want: if cur == want:
return self._out("done", {"sprint": want, "changed": False}) return self._out("done", {"sprint": want, "changed": False})
sp = self.sprints().get(want) sp = self.sprints(board).get(want)
if not sp: if not sp:
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s" return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
% (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—")) % (want, board, ", ".join(sorted(self.sprints(board))) or "—"))
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})
@ -1021,13 +1021,63 @@ class Executor:
out.append(self.comment_state(c)) out.append(self.comment_state(c))
return out return out
def sprint_names(self, keys: List[str]) -> Dict[str, str]: def board_type(self, board: Any) -> Optional[str]:
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, """Тип доски Jira: scrum или kanban; иное и ошибка Jira — None с записью в лог (refresh продолжается)."""
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). try:
t = self.jira.get(AGILE + "/board/%s" % board).get("type")
except HttpError as e:
log("refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code))
return None
if t in ("scrum", "kanban"):
return t
log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t))
return None
def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]:
"""(scrum-доски для имён спринтов, типы запрошенных досок для результата или None).
Без boards в команде — хаб до проектов: доска из настроек, как v11."""
if "boards" not in p:
board = self.s.get("board_id")
return ([board] if board else []), None
scrum: List[Any] = []
types: Dict[str, Optional[str]] = {}
seen = set()
for b in p.get("boards") or []:
if not isinstance(b, dict): # хаб шлёт [{id, type}]; иное не роняет refresh (M-2)
continue
bid = b.get("id")
if bid is None or str(bid) in seen:
continue
seen.add(str(bid))
t = b.get("type")
if t is None:
t = types[str(bid)] = self.board_type(bid)
if t == "scrum":
scrum.append(bid)
return scrum, (types or None)
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
(R-36); иначе ошибка Jira с номером доски, refresh failed (R-32, I-1)."""
try:
return self.sprints(board)
except HttpError as e:
if self.board_type(board) == "kanban":
types[str(board)] = "kanban"
return {}
raise HttpError(e.code, "доска %s: %s" % (board, e.text), e.headers) from None
def sprint_names(self, keys: List[str], boards: List[Any],
types: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]:
# Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет,
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок —
# побеждает последнее найденное имя.
names = {} names = {}
if not keys or not self.s.get("board_id"): if not keys:
return names return names
for name, sp in self.sprints().items(): types = {} if types is None else types
for board in boards:
for name, sp in self.board_sprints(board, types).items():
for i in range(0, len(keys), 100): for i in range(0, len(keys), 100):
chunk = keys[i:i + 100] chunk = keys[i:i + 100]
start = 0 start = 0
@ -1045,12 +1095,16 @@ class Executor:
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
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:
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0}
if types is not None:
empty["boards"] = types
return self._out("done", empty)
raws = [] raws = []
for i in range(0, len(keys), 100): for i in range(0, len(keys), 100):
raws += self.search_tolerant(keys[i:i + 100], missing, errors) raws += self.search_tolerant(keys[i:i + 100], missing, errors)
@ -1068,7 +1122,8 @@ 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
names = self.sprint_names([r["key"] for r in everything]) types = dict(types or {})
names = self.sprint_names([r["key"] for r in everything], scrum, types)
issues, links, seen = [], [], set() issues, links, seen = [], [], set()
for r in everything: for r in everything:
st = self.to_state(r) st = self.to_state(r)
@ -1095,6 +1150,8 @@ class Executor:
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
if missing: if missing:
result["missing"] = list(dict.fromkeys(missing))[:50] result["missing"] = list(dict.fromkeys(missing))[:50]
if types:
result["boards"] = types
return self._out("done", result) return self._out("done", result)

View File

@ -69,6 +69,9 @@ class FakeJira:
self.comments = {} self.comments = {}
self.links = [] self.links = []
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.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи)
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 = [] # [(код, {заголовки})] — следующие ответы с ошибкой
@ -349,17 +352,30 @@ class FakeJira:
tid = int(body["transition"]["id"]) - 1 tid = int(body["transition"]["id"]) - 1
self.issues[key]["status"] = trs[tid][1] self.issues[key]["status"] = trs[tid][1]
return 204, None return 204, None
m = re.match(r"^/rest/agile/1\.0/board/(\d+)$", p)
if m:
board = int(m.group(1))
if board not in self.boards:
return 404, {"errorMessages": ["Board does not exist"]}
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
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))
if board not in self.boards:
return 404, {"errorMessages": ["Board does not exist"]}
if self.boards.get(board) == "kanban":
return 400, {"errorMessages": ["The board does not support sprints"]}
states = (q.get("state") or "active,future").split(",") states = (q.get("state") or "active,future").split(",")
vals = [s for s in self.sprints if s["state"] in states] vals = [s for s in self.board_sprints.get(board, self.sprints) if 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)}
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.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":
keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] members = self.sprint_members.get(sp["id"])
keys = [k for k in self.search_keys(q.get("jql") or "key in ()")
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} for k in keys[start:start + maxr]]}
@ -373,12 +389,18 @@ class FakeJira:
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"]
sp = next((s for s in self.sprints if s["name"] == name), None) sp = next((s for s in self.all_sprints() if s["name"] == name), None)
if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет
return 200, {"key": m.group(1)} return 200, {"key": m.group(1)}
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 all_sprints(self):
out = list(self.sprints)
for vals in self.board_sprints.values():
out += vals
return out
def _type_name(self, ref): def _type_name(self, ref):
ref = ref or {} ref = ref or {}
if ref.get("name"): if ref.get("name"):

View File

@ -199,6 +199,49 @@ class ExecutorLinksTest(unittest.TestCase):
self.assertEqual(out["status"], "failed") self.assertEqual(out["status"], "failed")
self.assertIn("board_id", out["error"]) self.assertIn("board_id", out["error"])
def test_sprint_board_from_params(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None})
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K")
paths = [r[1] for r in self.fake.requests]
self.assertIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120)
self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False})
def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды»
self.ex.s["board_id"] = None
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216)
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
def test_sprint_unknown_name_names_param_board(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("5120", out["error"])
self.assertIn("Спринт K", out["error"])
def test_sprint_kanban_board_failed_with_jira_text(self):
self.fake.boards[5120] = "kanban"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120)
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 400", out["error"])
self.assertIn("does not support sprints", out["error"])
self.assertFalse(out["retryable"])
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
def test_sprint_backlog_ignores_board(self):
self.fake.boards[5120] = "kanban"
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120)
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
self.assertFalse(any("/board/" in r[1] for r in self.fake.requests))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -183,7 +183,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, 11) self.assertEqual(agent.AGENT_VERSION, 12)
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

@ -266,7 +266,7 @@ class RefreshTest(unittest.TestCase):
def test_sprint_names_paginated(self): def test_sprint_names_paginated(self):
for i in range(1, 121): for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2") self.fake.add("AURORA-%d" % i, sprint="Спринт 2")
names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)]) names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])
self.assertEqual(len(names), 100) self.assertEqual(len(names), 100)
pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"] pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"]
self.assertEqual(len(pages), 2) self.assertEqual(len(pages), 2)
@ -302,6 +302,131 @@ class RefreshTest(unittest.TestCase):
self.assertTrue(out["retryable"]) self.assertTrue(out["retryable"])
self.assertNotIn("peer", out) self.assertNotIn("peer", out)
JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
def refresh(self, **params):
return self.ex.run({"id": 5, "type": "refresh", "params": params})
def logged(self, fn):
logs, orig = [], agent.log
agent.log = logs.append
try:
return fn(), logs
finally:
agent.log = orig
def test_two_boards_one_new(self): # сценарий «Две доски, одна новая»
self.fake.boards[5120] = "kanban"
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
paths = [r[1] for r in self.fake.requests]
self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1)
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths)
self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_board_not_found(self): # сценарий «Доска не найдена»
self.fake.add("AURORA-1")
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"9999": None})
self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs)
self.assertEqual(len(self.hub.states), 1)
def test_new_scrum_board_sprints_read(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1", sprint="Спринт K")
out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "scrum"})
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K")
self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
def test_old_hub_without_boards(self): # сценарий «Старый хаб»
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL)
self.assertNotIn("boards", out["result"])
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_known_boards_no_type_requests(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertNotIn("boards", out["result"])
self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_empty_boards_no_settings_fallback(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[])
self.assertEqual(out["status"], "done", out)
self.assertNotIn("boards", out["result"])
self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests))
self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"])
def test_board_type_5xx_logged_refresh_continues(self):
self.fake.add("AURORA-1")
self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": None})
self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs)
self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб
def test_board_other_type_is_null(self):
self.fake.boards[7000] = "simple"
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}]))
self.assertEqual(out["result"]["boards"], {"7000": None})
self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs)
def test_duplicate_null_board_requested_once(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}])
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1)
def test_empty_keys_still_resolves_boards(self):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
def test_last_found_sprint_wins_across_boards(self):
self.fake.boards[5120] = "scrum"
self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}]
self.fake.add("AURORA-1")
self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]}
both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]
self.refresh(jql=self.JQL, boards=both)
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K")
self.refresh(jql=self.JQL, boards=both[::-1])
self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36
self.fake.boards[5120] = "kanban" # хаб думает, что scrum
self.fake.add("AURORA-1", sprint="Спринт 2")
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску
self.fake.add("AURORA-1")
out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira
self.assertEqual(out["status"], "failed")
self.assertIn("HTTP 404", out["error"])
self.assertIn("доска 9999", out["error"])
self.assertFalse(out["retryable"])
self.assertEqual(self.hub.states, [])
def test_non_dict_board_item_skipped(self): # M-2
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}])
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()