Merge pull request 'Агент v16: оценка через estimation доски (#53)' (#54) from feat/53-v16-sp-estimation into main
This commit is contained in:
commit
6423a5c2ad
@ -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/<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 — под блокировкой журнала) | файловая система |
|
||||
| Исполнитель | `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` |
|
||||
|
||||
@ -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`.
|
||||
|
||||
## Выкатка новой версии
|
||||
|
||||
@ -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`: «обновите агента»; команды
|
||||
|
||||
48
docs/superpowers/plans/2026-10-25-sp-estimation-agent.md
Normal file
48
docs/superpowers/plans/2026-10-25-sp-estimation-agent.md
Normal 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)».
|
||||
36
openspec/changes/agent-v16-sp-estimation/design.md
Normal file
36
openspec/changes/agent-v16-sp-estimation/design.md
Normal 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` без записи | иначе число уйдёт в чужое поле доски другого проекта | лишний отказ, если поле настроено неверно — правится настройкой |
|
||||
38
src/agent.py
38
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,37 @@ 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
|
||||
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"]
|
||||
fields, changed = {}, []
|
||||
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"]):
|
||||
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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -294,5 +294,84 @@ 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, [])
|
||||
|
||||
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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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__":
|
||||
|
||||
@ -263,11 +263,11 @@ 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)
|
||||
self.assertEqual(self.hub.states[0]["agent_version"], 16)
|
||||
|
||||
def test_input_keys_deduplicated(self):
|
||||
self.fake.add("AURORA-1")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user