From 3dbca034ad74b86a9d1a1eae7a09c5522cc78eb7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:39:19 +0400 Subject: [PATCH 1/5] Design-only change agent-v16-sp-estimation (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Оценка из ensure_fields через /rest/agile/1.0/issue/{key}/estimation доски: поле оценки не на экране редактирования Jira банка. Контракт — sdlc-hub#113. Co-Authored-By: Claude Opus 5.5 --- .../changes/agent-v16-sp-estimation/design.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 openspec/changes/agent-v16-sp-estimation/design.md diff --git a/openspec/changes/agent-v16-sp-estimation/design.md b/openspec/changes/agent-v16-sp-estimation/design.md new file mode 100644 index 0000000..33abe4c --- /dev/null +++ b/openspec/changes/agent-v16-sp-estimation/design.md @@ -0,0 +1,34 @@ +# 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=`, сравнение + `_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 отдаёт число; живая проверка до пакета | одна правка агента | -- 2.47.2 From 3350d0066a9da925238dc79dfbbdbb9b6c0a9623 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:40:40 +0400 Subject: [PATCH 2/5] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20v16,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D1=83=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-25-sp-estimation-agent.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-25-sp-estimation-agent.md diff --git a/docs/superpowers/plans/2026-10-25-sp-estimation-agent.md b/docs/superpowers/plans/2026-10-25-sp-estimation-agent.md new file mode 100644 index 0000000..7fc1238 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-sp-estimation-agent.md @@ -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)». -- 2.47.2 From cb8203f0e14eb25c71177d4b30f71e05b7b04750 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:49:05 +0400 Subject: [PATCH 3/5] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v16:=20?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20estimation=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B8=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensure_fields со story_points и board_id читает и пишет оценку через /rest/agile/1.0/issue/{key}/estimation?boardId=… — поле оценки в Jira банка не на экране редактирования, PUT /issue его отклоняет. Остальные поля — как раньше; без board_id — путь v15. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- README.md | 4 ++- src/agent.py | 31 ++++++++++++++----- tests/fake_jira.py | 12 ++++++++ tests/test_executor_fields.py | 58 +++++++++++++++++++++++++++++++++++ tests/test_folder.py | 2 +- tests/test_history.py | 2 +- tests/test_refresh.py | 4 +-- 9 files changed, 102 insertions(+), 15 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e18661c..96b6fa4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `sdlc-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (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` | | Цикл | `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` | diff --git a/CLAUDE.md b/CLAUDE.md index 3995001..69e4638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, 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`. ## Выкатка новой версии diff --git a/README.md b/README.md index cc497b4..6c8ee85 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ python3 agent.py prune | Тип | Что делает | |---|---| | `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_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | @@ -215,6 +215,8 @@ python3 agent.py prune Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points` в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field` (ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`. + Версия 16 — оценка из `ensure_fields` с `board_id` через `/rest/agile/1.0/issue/{key}/estimation` доски: в Jira банка поле + оценки не на экране редактирования, `PUT /issue` его отклоняет (#53). Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды diff --git a/src/agent.py b/src/agent.py index f79b6b5..919c6a4 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 15 +AGENT_VERSION = 16 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new HERE = os.path.dirname(os.path.abspath(__file__)) @@ -874,14 +874,30 @@ class Executor: def do_ensure_fields(self, p: dict, cmd: dict) -> dict: key = p["key"] sp_fid = None + # С доской оценка — через estimation доски (v16): поля может не быть на экране редактирования (#53) + by_board = "story_points" in p and bool(p.get("board_id")) 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. sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"] if not sp_fid: raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points") - want_sp = p["story_points"] - if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0): - return self._out("failed", error="story_points — число ≥ 0 или null") + changed: List[str] = [] + if set(p) - {"key", "board_id", "story_points"} or not by_board: + changed = self.ensure_issue_fields(key, p, sp_fid) + if by_board: + path = AGILE + "/issue/%s/estimation" % key + if not _same_points(self.jira.get(path, boardId=p["board_id"]).get("value"), p["story_points"]): + 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"] fields, changed = {}, [] if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): @@ -923,10 +939,9 @@ class Executor: if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]): fields[sp_fid] = p["story_points"] changed.append("story_points") - if not fields: - return self._out("done", {"changed": []}) - self.jira.put(API + "/issue/%s" % key, {"fields": fields}) - return self._out("done", {"changed": changed}) + if fields: + self.jira.put(API + "/issue/%s" % key, {"fields": fields}) + return changed # --- ensure_comment -------------------------------------------------------- def do_ensure_comment(self, p: dict, cmd: dict) -> dict: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index ff27520..c737575 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -86,6 +86,7 @@ class FakeJira: self.sprint_seq = 500 # id следующего созданного спринта self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank + self.estimation_bodies = [] # тела PUT /rest/agile/1.0/issue/{key}/estimation self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 @@ -557,6 +558,17 @@ class FakeJira: return 204, None if p == "/rest/agile/1.0/issue/rank" and method == "PUT": return self._rank(body) + m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)/estimation$", p) + if m: # оценка задачи на доске boardId (поле оценки доски — POINTS_FIELD) + if m.group(1) not in self.issues: + return 404, {"errorMessages": ["Issue Does Not Exist"]} + if int(q.get("boardId") or 0) not in self.boards: + return 400, {"errorMessages": ["The board %s does not exist" % q.get("boardId")]} + issue = self.issues[m.group(1)] + if method == "PUT": + self.estimation_bodies.append(body) + issue[POINTS_FIELD] = body.get("value") + return 200, {"fieldId": POINTS_FIELD, "value": issue.get(POINTS_FIELD)} m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) if m: name = self.issues[m.group(1)]["sprint"] diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index 7b4846a..20bdf5b 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -294,5 +294,63 @@ class ExecutorFieldsTest(unittest.TestCase): 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, []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_folder.py b/tests/test_folder.py index 80c3454..a0eba87 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -197,7 +197,7 @@ class FolderIntegrationTest(unittest.TestCase): return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 15) + self.assertEqual(agent.AGENT_VERSION, 16) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_history.py b/tests/test_history.py index 7ff5521..fe0a2b0 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -310,7 +310,7 @@ class RefreshHistoryTest(Base): self.assertEqual(self.changelog_queries(), []) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 15) + self.assertEqual(agent.AGENT_VERSION, 16) if __name__ == "__main__": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index f090357..5dd378c 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -263,8 +263,8 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done") self.assertEqual(out["result"]["missing"], ["GONE-1"]) - def test_agent_version_15(self): - self.assertEqual(agent.AGENT_VERSION, 15) + def test_agent_version_16(self): + self.assertEqual(agent.AGENT_VERSION, 16) self.fake.add("AURORA-1") self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) self.assertEqual(self.hub.states[0]["agent_version"], 15) -- 2.47.2 From a3247d6f2ddcc18e5b9b71696cd6b6972f847144 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 21:52:45 +0400 Subject: [PATCH 4/5] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=81=D0=B8=D0=B8=20=D0=B2=20=D1=81=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8=20refresh=20=E2=80=94=2016=20(#5?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/test_refresh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 5dd378c..627b307 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -267,7 +267,7 @@ class RefreshTest(unittest.TestCase): self.assertEqual(agent.AGENT_VERSION, 16) self.fake.add("AURORA-1") self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) - self.assertEqual(self.hub.states[0]["agent_version"], 15) + self.assertEqual(self.hub.states[0]["agent_version"], 16) def test_input_keys_deduplicated(self): self.fake.add("AURORA-1") -- 2.47.2 From 20747c2b655d6c4fdc7c41e61c5aa935f5a65b87 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 25 Oct 2026 22:28:19 +0400 Subject: [PATCH 5/5] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v16:=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B7=D0=BE=D1=80=20=D0=BC=D0=B5=D0=B6=D0=B4=D1=83?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8F=D0=BC=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=20(#53,=20I-1,=20I-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1: если ensure_fields записал поля через PUT /issue, перед записью оценки — зазор ensure_fields. I-2: fieldId оценки доски сверяется с field_ids.story_points; расхождение — failed без записи. Co-Authored-By: Claude Opus 5.5 --- .../changes/agent-v16-sp-estimation/design.md | 2 ++ src/agent.py | 9 +++++++- tests/test_executor_fields.py | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/openspec/changes/agent-v16-sp-estimation/design.md b/openspec/changes/agent-v16-sp-estimation/design.md index 33abe4c..7e4c336 100644 --- a/openspec/changes/agent-v16-sp-estimation/design.md +++ b/openspec/changes/agent-v16-sp-estimation/design.md @@ -32,3 +32,5 @@ the appropriate screen, or unknown». Поле оценки не на экран | 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` без записи | иначе число уйдёт в чужое поле доски другого проекта | лишний отказ, если поле настроено неверно — правится настройкой | diff --git a/src/agent.py b/src/agent.py index 919c6a4..942a53a 100644 --- a/src/agent.py +++ b/src/agent.py @@ -890,7 +890,14 @@ class Executor: changed = self.ensure_issue_fields(key, p, sp_fid) if by_board: path = AGILE + "/issue/%s/estimation" % key - if not _same_points(self.jira.get(path, boardId=p["board_id"]).get("value"), p["story_points"]): + 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}) diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index 20bdf5b..12f98c2 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -352,5 +352,26 @@ class ExecutorFieldsTest(unittest.TestCase): self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null")) self.assertEqual(self.fake.requests, []) + def test_points_by_board_gap_after_fields(self): # I-1: зазор ensure_fields между двумя записями + gaps = [] + self.ex.sleep = gaps.append + self.ex.s["pacing"] = {"ensure_fields": 120} + self.points("AURORA-1", None) + self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=3, board_id=4216) + self.assertEqual(gaps, [120]) + self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-30", story_points=5, board_id=4216) + self.assertEqual(gaps, [120]) # PUT /issue не было — паузы нет + + def test_points_by_board_other_field(self): # I-2: оценка доски — не поле story points + self.points("AURORA-1", 1.0) + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000") + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216) + self.assertEqual((out["status"], out["error"]), + ("failed", "у доски 4216 оценка — поле customfield_10106, а не story points (customfield_20000)")) + self.assertEqual(self.fake.estimation_bodies, []) + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_10106") + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=2, board_id=4216) + self.assertEqual(out["result"], {"changed": ["story_points"]}) + if __name__ == "__main__": unittest.main() -- 2.47.2