Merge pull request 'Агент v16: оценка через estimation доски (#53)' (#54) from feat/53-v16-sp-estimation into main

This commit is contained in:
aep 2026-10-25 21:29:09 +03:00
commit 6423a5c2ad
11 changed files with 215 additions and 16 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` настроек — запасной; 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`), настройки хаба | | Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной; v15: поля ранга, спринта и оценки — `jira_fields` по `field_list` (`GET /field` один раз на команду; перекрытие `field_ids`), доски — `board_state` (спринты через `all_sprints`), новые `do_ensure_sprint_meta`, `do_ensure_sprint_state`, `do_ensure_rank`, `story_points` в `do_ensure_fields`; v16: оценка с `board_id` — через `/rest/agile/1.0/issue/{key}/estimation` доски, остальные поля — `ensure_issue_fields` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба |
| Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Самообновление | `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 — `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг, оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — `docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md`. Последний change — `../sdlc-hub/openspec/changes/sp-estimation-board/` (агент v16: оценка через доску; решения агента — `openspec/changes/agent-v16-sp-estimation/design.md`), план — `docs/superpowers/plans/2026-10-25-sp-estimation-agent.md`.
План и спека живут в `sdlc-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. План и спека живут в `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,7 +168,7 @@ 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` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка) | | `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка); с v16 при `board_id` в команде (хаб ставит доску проекта) оценка читается и пишется через `/rest/agile/1.0/issue/{key}/estimation?boardId=…` — как в бэклоге Scrum, без экрана редактирования; `GET /issue` — только если есть другие поля |
| `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт |
| `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных |
| `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее |
@ -215,6 +215,8 @@ python3 agent.py prune
Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points` Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points`
в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field` в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field`
(ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`. (ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`.
Версия 16 — оценка из `ensure_fields` с `board_id` через `/rest/agile/1.0/issue/{key}/estimation` доски: в Jira банка поле
оценки не на экране редактирования, `PUT /issue` его отклоняет (#53).
Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает
«неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки. «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки.
Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды

View File

@ -0,0 +1,48 @@
# Агент v16: оценка через доску Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `ensure_fields` со `story_points` и `board_id` ставит оценку через `/rest/agile/1.0/issue/{key}/estimation?boardId=…`.
**Architecture:** в `Executor.do_ensure_fields` ветка оценки с доской отделяется от `PUT /issue`: сначала остальные
поля (как v15, `GET /issue` только если они есть), затем `GET`/`PUT` оценки доски. Без `board_id` — путь v15.
**Tech Stack:** Python 3.9 stdlib, unittest; фейк Jira — `tests/fake_jira.py`.
**Spec:** `sdlc-hub/openspec/changes/sp-estimation-board/specs/agent/spec.md`, `…/specs/commands/spec.md`; решения — `openspec/changes/agent-v16-sp-estimation/design.md`.
## Global Constraints
- `AGENT_VERSION = 16`; Python 3.9, только stdlib (`tests/test_py39.py`).
- Тело `PUT`: `{"value": <число или null>}`; путь `AGILE + "/issue/%s/estimation"` с параметром `boardId`.
- Сравнение — `_same_points`; плохое значение — `failed` «story_points — число ≥ 0 или null» до вызовов Jira.
## Review Focus
1. Команда только с оценкой — без `GET /issue` и `PUT /issue`; `jira_calls` 2 (запись) и 1 (без изменений).
2. Оценка и другие поля — `PUT /issue` без поля оценки, затем `PUT estimation`; `changed` — все поля.
3. Ошибка Jira на `PUT estimation` (400) — `failed`, остальные поля уже записаны.
4. `null` при пустой оценке — без записи; при `1.0` — `PUT {"value": null}`.
5. Без `board_id` — поведение v15 (существующие тесты `test_story_points_*` не меняются).
---
### Task 1: Оценка через estimation доски
**Files:**
- Modify: `tests/fake_jira.py` (маршрут `/rest/agile/1.0/issue/{key}/estimation`: `GET` → `{"fieldId": POINTS_FIELD, "value": <поле задачи>}`, `PUT` → пишет `value` в поле задачи, тело в `self.estimation_bodies`; доска не из `self.boards` → 400; задачи нет → 404; маршрут — до общего `/rest/agile/1.0/issue/([^/]+)$`)
- Modify: `src/agent.py` (`do_ensure_fields`, `AGENT_VERSION = 16`)
- Test: `tests/test_executor_fields.py` (раздел `# --- story_points через доску (#53)`)
- [ ] **Step 1: Тесты:**
- `test_points_by_board_set` — оценка `1.0`, `story_points=2, board_id=4216`: `result == {"changed": ["story_points"]}`, `jira_calls == 2`, `estimation_bodies == [{"value": 2}]`, `("PUT", "/rest/api/2/issue/AURORA-1704")` и `("GET", "/rest/api/2/issue/AURORA-1704")` нет в `requests`;
- `test_points_by_board_same` — оценка `2.0`, `story_points=2`: `changed == []`, `jira_calls == 1`, записей нет;
- `test_points_by_board_clear` — оценка `1.0`, `story_points=None` → тело `{"value": None}`; при пустой оценке — без записи;
- `test_points_by_board_with_fields` — `duedate` + `story_points`: `PUT /issue` без `customfield_10106`, потом `PUT estimation`; `changed == ["duedate", "story_points"]`;
- `test_points_by_board_jira_error` — доска 9999 (нет у фейка): `failed`, `duedate` уже записан;
- `test_points_by_board_no_field_lookup` — `fake.fields = []`: всё равно `done` (без `GET /field`);
- `test_points_by_board_bad_value` — `"3"` → `failed`, `requests` пуст.
- [ ] **Step 2:** `/usr/bin/python3 -m unittest tests.test_executor_fields` — FAIL.
- [ ] **Step 3:** `do_ensure_fields`: при `board_id` и `story_points` — путь доски (R-1…R-3 design), иначе v15; `AGENT_VERSION = 16`.
- [ ] **Step 4:** `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёный.
- [ ] **Step 5:** commit «Агент v16: оценка через estimation доски (#53)».

View File

@ -0,0 +1,36 @@
# agent-v16-sp-estimation — решения агента
Спека и дизайн изменения — в `sdlc-hub/openspec/changes/sp-estimation-board/` (issue aep/sdlc-hub#113, aep/jira-agent#53).
Агентская часть: требование `agent` «Спринты, ранг и оценки» (MODIFIED: оценка через доску) и строка `ensure_fields`
в `commands` «Команда — желаемый результат на одной задаче» (MODIFIED: `board_id` при `story_points`, чтение и запись
оценки через `/rest/agile/1.0/issue/{key}/estimation`). Версия агента — 16.
## Причина
Jira банка 25.10.2026 отклонила `PUT /rest/api/2/issue/{key}` с `customfield_15852`: «Field cannot be set. It is not on
the appropriate screen, or unknown». Поле оценки не на экране редактирования; бэклог Scrum ставит его через доску.
Владелец проверил в браузере: `GET /rest/agile/1.0/issue/AURORA-1704/estimation?boardId=4216` →
`{"fieldId":"customfield_15852","value":1.0}`.
## Решения агента
1. **`ensure_fields` с `story_points` и `board_id`:** `GET AGILE/issue/{key}/estimation?boardId=<board_id>`, сравнение
`_same_points(value, want)`; отличается — `PUT` туда же `{"value": want}` (`null` — очистить). `field_ids.story_points`
и `GET /field` для этого не нужны.
2. **Остальные поля — как v15**, `PUT /issue` только с ними; `GET /issue` — только если в команде есть поля, кроме
оценки (`key`, `board_id`, `story_points` не считаются).
3. **Порядок:** сначала `PUT /issue` с остальными полями, затем оценка. Ошибка оценки — исключение HTTP наружу, как у
прочих вызовов Jira (команда `failed` с текстом Jira).
4. **Без `board_id`** — оценка через `PUT /issue` в поле `field_ids.story_points`/найденное по имени, как v15.
5. **Проверка значения** (`число ≥ 0 или null`) — до любых вызовов Jira, как v15.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 25.10.2026 | R-1: без `board_id` — путь v15 | совместимость с хабом до #113 | нет |
| 25.10.2026 | R-2: сначала остальные поля, затем оценка | новый путь не должен ломать остальные поля | частичная запись, повтор безопасен |
| 25.10.2026 | R-3: без `GET /issue`, если в команде только оценка | минус вызов Jira на команду в пакете оценок | нет |
| 25.10.2026 | R-5: `value` — число как есть | Jira отдаёт число; живая проверка до пакета | одна правка агента |
| 25.10.2026 | I-1 (финальное ревью): между `PUT /issue` и `PUT …/estimation` одной команды — зазор `ensure_fields` | политика темпа: зазор между записями внутри команды | пакет «оценка + поля» идёт медленнее |
| 25.10.2026 | I-2 (финальное ревью): `fieldId` оценки доски сверяется с `field_ids.story_points` (если задан), расхождение — `failed` без записи | иначе число уйдёт в чужое поле доски другого проекта | лишний отказ, если поле настроено неверно — правится настройкой |

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 = 15 AGENT_VERSION = 16
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__))
@ -874,14 +874,37 @@ class Executor:
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 sp_fid = None
# С доской оценка — через estimation доски (v16): поля может не быть на экране редактирования (#53)
by_board = "story_points" in p and bool(p.get("board_id"))
if "story_points" in p: if "story_points" in p:
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")
if "story_points" in p and not by_board:
# Настроенный id берётся без GET /field (как в refresh он не нужен); иначе — поиск в jira_fields. # Настроенный id берётся без GET /field (как в refresh он не нужен); иначе — поиск в jira_fields.
sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"] sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"]
if not sp_fid: if not sp_fid:
raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points") raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points")
want_sp = p["story_points"] changed: List[str] = []
if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0): if set(p) - {"key", "board_id", "story_points"} or not by_board:
return self._out("failed", error="story_points — число ≥ 0 или null") changed = self.ensure_issue_fields(key, p, sp_fid)
if by_board:
path = AGILE + "/issue/%s/estimation" % key
est = self.jira.get(path, boardId=p["board_id"])
own = (self.s.get("field_ids") or {}).get("story_points")
if own and est.get("fieldId") and est["fieldId"] != own: # доска оценивает другим полем — не писать туда
return self._out("failed", error="у доски %s оценка — поле %s, а не story points (%s)"
% (p["board_id"], est["fieldId"], own))
if not _same_points(est.get("value"), p["story_points"]):
if changed: # вторая запись команды — по зазору типа (политика темпа)
self.gap("ensure_fields")
self.jira.call("PUT", path, {"value": p["story_points"]}, params={"boardId": p["board_id"]})
changed.append("story_points")
return self._out("done", {"changed": changed})
def ensure_issue_fields(self, key: str, p: dict, sp_fid: Optional[str]) -> List[str]:
"""Поля ensure_fields через GET/PUT /issue: только отличающиеся; оценка — здесь лишь при sp_fid (путь v15).
Возвращает имена записанных полей."""
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 ""):
@ -923,10 +946,9 @@ class Executor:
if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]): if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]):
fields[sp_fid] = p["story_points"] fields[sp_fid] = p["story_points"]
changed.append("story_points") changed.append("story_points")
if not fields: if fields:
return self._out("done", {"changed": []}) self.jira.put(API + "/issue/%s" % key, {"fields": fields})
self.jira.put(API + "/issue/%s" % key, {"fields": fields}) return changed
return self._out("done", {"changed": changed})
# --- ensure_comment -------------------------------------------------------- # --- ensure_comment --------------------------------------------------------
def do_ensure_comment(self, p: dict, cmd: dict) -> dict: def do_ensure_comment(self, p: dict, cmd: dict) -> dict:

View File

@ -86,6 +86,7 @@ class FakeJira:
self.sprint_seq = 500 # id следующего созданного спринта self.sprint_seq = 500 # id следующего созданного спринта
self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id}
self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank
self.estimation_bodies = [] # тела PUT /rest/agile/1.0/issue/{key}/estimation
self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется self.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
@ -557,6 +558,17 @@ class FakeJira:
return 204, None return 204, None
if p == "/rest/agile/1.0/issue/rank" and method == "PUT": if p == "/rest/agile/1.0/issue/rank" and method == "PUT":
return self._rank(body) return self._rank(body)
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)/estimation$", p)
if m: # оценка задачи на доске boardId (поле оценки доски — POINTS_FIELD)
if m.group(1) not in self.issues:
return 404, {"errorMessages": ["Issue Does Not Exist"]}
if int(q.get("boardId") or 0) not in self.boards:
return 400, {"errorMessages": ["The board %s does not exist" % q.get("boardId")]}
issue = self.issues[m.group(1)]
if method == "PUT":
self.estimation_bodies.append(body)
issue[POINTS_FIELD] = body.get("value")
return 200, {"fieldId": POINTS_FIELD, "value": issue.get(POINTS_FIELD)}
m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) 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"]

View File

@ -294,5 +294,84 @@ class ExecutorFieldsTest(unittest.TestCase):
self.assertEqual(self.fake.writes(), []) self.assertEqual(self.fake.writes(), [])
# --- story_points через доску (#53) ------------------------------------------
def points(self, key, value):
self.fake.add(key, **({} if value is None else {"customfield_10106": value}))
def test_points_by_board_set(self): # сценарий «Оценка не на экране редактирования»
self.points("AURORA-1704", 1.0)
out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216)
self.assertEqual((out["status"], out["result"], out["jira_calls"]), ("done", {"changed": ["story_points"]}, 2))
self.assertEqual(self.fake.estimation_bodies, [{"value": 2}])
self.assertEqual(self.fake.issues["AURORA-1704"]["customfield_10106"], 2)
self.assertEqual(self.fake.queries[-1], {"boardId": "4216"})
for call in (("GET", "/rest/api/2/issue/AURORA-1704"), ("PUT", "/rest/api/2/issue/AURORA-1704"),
("GET", "/rest/api/2/field")):
self.assertNotIn(call, self.fake.requests)
def test_points_by_board_same(self): # сценарий «Оценка уже стоит»
self.points("AURORA-1704", 2.0)
out = self.run_cmd("ensure_fields", key="AURORA-1704", story_points=2, board_id=4216)
self.assertEqual((out["result"], out["jira_calls"]), ({"changed": []}, 1))
self.assertEqual(self.fake.estimation_bodies, [])
def test_points_by_board_clear(self):
self.points("AURORA-1", 1.0)
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216)
self.assertEqual(out["result"], {"changed": ["story_points"]})
self.assertEqual(self.fake.estimation_bodies, [{"value": None}])
again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=None, board_id=4216)
self.assertEqual((again["result"], again["jira_calls"]), ({"changed": []}, 1))
def test_points_by_board_with_fields(self):
self.points("AURORA-1", None)
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216)
self.assertEqual(out["result"], {"changed": ["duedate", "story_points"]})
writes = [r for r in self.fake.requests if r[0] == "PUT"]
self.assertEqual(writes, [("PUT", "/rest/api/2/issue/AURORA-1"), ("PUT", "/rest/agile/1.0/issue/AURORA-1/estimation")])
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30")
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3)
def test_points_by_board_jira_error(self):
self.points("AURORA-1", None)
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=9999)
self.assertEqual(out["status"], "failed")
self.assertIn("9999", out["error"])
self.assertEqual(self.fake.issues["AURORA-1"]["duedate"], "2026-10-30")
def test_points_by_board_no_field_lookup(self):
self.points("AURORA-1", None)
self.fake.fields = []
self.ex.s["field_ids"] = {k: v for k, v in SETTINGS["field_ids"].items() if k != "story_points"}
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3, board_id=4216)
self.assertEqual((out["status"], out["result"]), ("done", {"changed": ["story_points"]}))
def test_points_by_board_bad_value(self):
self.points("AURORA-1", None)
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points="3", board_id=4216)
self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"))
self.assertEqual(self.fake.requests, [])
def test_points_by_board_gap_after_fields(self): # I-1: зазор ensure_fields между двумя записями
gaps = []
self.ex.sleep = gaps.append
self.ex.s["pacing"] = {"ensure_fields": 120}
self.points("AURORA-1", None)
self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216)
self.assertEqual(gaps, [120])
self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=5, board_id=4216)
self.assertEqual(gaps, [120]) # PUT /issue не было — паузы нет
def test_points_by_board_other_field(self): # I-2: оценка доски — не поле story points
self.points("AURORA-1", 1.0)
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216)
self.assertEqual((out["status"], out["error"]),
("failed", "у доски 4216 оценка — поле customfield_10106, а не story points (customfield_20000)"))
self.assertEqual(self.fake.estimation_bodies, [])
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_10106")
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216)
self.assertEqual(out["result"], {"changed": ["story_points"]})
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

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, 15) self.assertEqual(agent.AGENT_VERSION, 16)
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, 15) self.assertEqual(agent.AGENT_VERSION, 16)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -263,11 +263,11 @@ 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): def test_agent_version_16(self):
self.assertEqual(agent.AGENT_VERSION, 15) self.assertEqual(agent.AGENT_VERSION, 16)
self.fake.add("AURORA-1") self.fake.add("AURORA-1")
self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
self.assertEqual(self.hub.states[0]["agent_version"], 15) self.assertEqual(self.hub.states[0]["agent_version"], 16)
def test_input_keys_deduplicated(self): def test_input_keys_deduplicated(self):
self.fake.add("AURORA-1") self.fake.add("AURORA-1")