План: агент v15, спринты (#49)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:21:28 +04:00
parent 9eddc13b62
commit 0edea89133

View File

@ -0,0 +1,673 @@
# Спринты, ранг и оценки (агент v15) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** агент v15 в каждом `refresh` присылает хабу ранг, оценку и все спринты задач, данные досок (колонки,
kanban-бэклог, все спринты scrum-доски) и выполняет новые команды `ensure_sprint_meta`, `ensure_sprint_state`,
`ensure_rank` и поле `story_points` в `ensure_fields`.
**Architecture:** всё в `src/agent.py`, класс `Executor`. Поля Jira (ранг, спринт, оценка) ищутся по `GET /rest/api/2/field`
(кэш на экземпляр `Executor`, перекрытие `field_ids`) и добавляются в `fields` поиска `refresh`; данные досок читаются
после определения типов досок (`refresh_boards`) и уходят с первой пачкой `POST /agent/state`. Новые команды — методы
`do_<тип>` по образцу `do_ensure_sprint` / `do_ensure_link` («проверь, потом сделай», `self.gap(<тип>)` между записями).
**Tech Stack:** Python 3.9 (`/usr/bin/python3`), только stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`.
**Spec:** `../sdlc-hub/openspec/changes/sprints-backlog/` (полный путь
`/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints/openspec/changes/sprints-backlog/`: `design.md`
решения 10–13, 18–20, 24 и «Контракт данных»; `specs/agent/spec.md` «Спринты, ранг и оценки»; `specs/commands/spec.md`
таблица типов и абзацы о командах спринтов; `specs/hub-api/spec.md` «Приём досок и спринтов») и
`openspec/changes/agent-v15-sprints/design.md` этого репозитория (решения 1–9, R-60…R-63).
**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/v15-sprints`, ветка `feat/49-v15-sprints`
(aep/jira-agent#49). Все команды — из корня worktree. Хабовая часть — отдельный план в `sdlc-hub`.
## Global Constraints
- `AGENT_VERSION = 15`.
- Python 3.9.6, только stdlib; без `match`, без `X | Y` в аннотациях функций (`tests/test_py39.py`).
- Поиск полей (design агента 1): ранг — `schema.custom == "com.pyxis.greenhopper.jira:gh-lexo-rank"`; спринт —
`schema.custom == "com.pyxis.greenhopper.jira:gh-sprint"`; оценка — имя «Story Points» без учёта регистра и пробелов
по краям; несколько кандидатов — первый по ключу `(len(id), id)`. Перекрытие — `field_ids.rank`, `field_ids.sprint`,
`field_ids.story_points` настроек хаба.
- Поля задачи в состоянии: `rank` (строка или `None`), `story_points` (`int`/`float`, не `bool`, иначе `None`),
`sprint_ids` (список целых, без повторов, порядок Jira). Поле не найдено — ключа у задач нет (R-64).
- Элемент `boards`: `{"id", "type", "columns": [{"name", "statuses": [имя статуса]}] | None, "kanban_backlog": True | False | None,
"sprints": [{"id", "name", "state", "start_date", "end_date", "complete_date", "goal"}]}`; даты — строки Jira как есть.
`boards` — только в первой пачке; при пустой выборке — одна пачка `{issues: [], links: [], neighbor_keys: [], page: 1, last_page: True, boards}`.
- Даты команд: `ГГГГ-ММ-ДД` → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` (начало) и `ГГГГ-ММ-ДДT18:00:00.000<смещение>` (конец),
смещение — `datetime.now().astimezone().strftime("%z")` (вид `+0300`, R-68); сравнение с Jira — `_date()` (первые 10 символов).
- Результаты: `ensure_sprint_meta` — `{"sprint_id", "created"}`; `ensure_sprint_state` — `{"sprint_id", "moved": [ключ…]}`;
`ensure_rank` — `{"changed"}`; `ensure_fields` — как раньше, `"story_points"` в `changed`.
- Тексты ошибок (точно): «поле оценки не найдено в Jira: задайте field_ids.story_points»; «поле ранга не найдено»;
«спринт закрыт»; «спринт не начат»; «имя занято»; «у спринта нет дат: задайте ensure_sprint_meta»;
«не удалось прочитать колонки доски»; «несколько спринтов с именем «<имя>»: <id>, <id>».
- Числа оценок равны, если `abs(a - b) < 1e-9`; ранги сравниваются как строки.
- Каждый коммит — русский заголовок с `(#49)` в конце первой строки и последней строкой
`Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`; комментарии и docstring — по-русски.
- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .`
- Решения исполнителя вне плана — в таблицу `openspec/changes/agent-v15-sprints/design.md` (следующий свободный `R-n`);
находки ревью задачи n — `T<n>.<k>`.
## Review Focus
1. `PUT /rest/agile/1.0/issue/rank` ответил 207 Multi-Status с ошибкой по задаче (Jira Agile так сообщает о частичном
отказе) — `failed` с текстом Jira, а не `{changed: true}` (тест `test_rank_207_error` в Task 7).
2. Спринт доски с состоянием вне `active`/`future`/`closed` или без `id`/`name` — хаб отверг бы всю пачку (`bad_state`);
агент отбрасывает такой спринт с записью в лог, остальное уходит (тест `test_board_bad_sprint_dropped` в Task 3).
3. Повтор `ensure_sprint_meta {sprint: "S", rename_to: "T"}` после успешного переименования — спринта «S» уже нет, есть «T»:
`done` без записи, а не второй спринт и не «имя занято» (тест `test_meta_repeat_after_rename` в Task 5).
4. `ensure_sprint_state closed` с `move_open_to` — неизвестное имя, закрытый спринт или тот же спринт — `failed` до
первой записи, Jira не изменена (тест `test_state_bad_move_target_no_writes` в Task 6).
5. Дата не в формате `ГГГГ-ММ-ДД` (`"28.10.2026"`, `"2026-13-01"`) — `failed` без вызова записи, а не 400 Jira или
спринт с неверной датой (тест `test_meta_bad_date` в Task 5).
## Rulings при планировании
Маркеры продолжают нумерацию design агента (R-60…R-63). Task 8 переносит их в таблицу `design.md`.
| Маркер | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| R-64 | Поле ранга/спринта/оценки не найдено — ключ `rank`/`sprint_ids`/`story_points` у задач не передаётся (design агента 2–3), а не `null` (спека `agent`: «значение null у всех задач») | Сбой `GET /field` с `null` стёр бы значения в зеркале; хаб без поля сохраняет прежнее, у новой базы прежнее — `NULL` | Поле удалили в Jira — в зеркале остаются старые значения до ручной правки |
| R-65 | Ошибка чтения спринтов доски — доска не входит в `boards` (хаб сохраняет прежние данные), причина в лог; ошибка чтения настроек при прочитанных спринтах — доска с `columns: null` | Доска из `boards` заменяет все свои спринты: `sprints: []` стёр бы историю; спека «прежние правила v12 … refresh продолжается» внутренне противоречива | Данные доски устаревают до следующего удачного `refresh` |
| R-66 | Пустая пачка с `boards` — только когда в параметрах есть `boards` (хаб v15) и выборка пуста (`jql` без задач, `keys: []`); все ключи 400 — по-прежнему `failed` (v14, `test_all_keys_400_fails`); `pages` считает и пустую пачку | Без `boards` пустая пачка бесполезна; «ни один ключ не найден» — признак ошибки конфигурации (R-7 v4) | Хвост `refresh` по ключам удалённых задач — `failed`, данные досок в этот раз не обновятся |
| R-67 | Любой `HttpError` на `GET /field` — поля не найдены до конца команды, следующая команда спрашивает снова; `ConnectionError` — как везде (повтор `refresh`); список полей один на команду и общий с журналом изменений (`field_id_of`) | design агента 1; один `GET /field` за `refresh` сохраняет `test_custom_field_lookup_once` | Нет |
| R-68 | Смещение дат спринта — `datetime.now().astimezone().strftime("%z")`, не `time.strftime("%z")` (design агента 5) | `time.strftime("%z")` на Windows отдаёт имя пояса, а не `+0300` | Нет |
| R-69 | `ensure_sprint_meta`: спринта `sprint` нет, а ровно один не закрытый спринт с именем `rename_to` есть — это он (повтор после переименования), правятся его поля; есть оба — «имя занято»; «несколько с именем» проверяется и для `rename_to` | Повтор команды не должен падать или создавать второй спринт (Anti-Goal proposal) | Нельзя создать новый спринт с именем существующего через `rename_to` — и не нужно |
| R-70 | У `ensure_sprint_meta` и `ensure_sprint_state` `board_id` только из команды, без запасного `board_id` настроек; нет — `failed` «board_id нет в команде — спринты недоступны» | Новые типы ставит только хаб v15, он всегда дописывает доску; чужая доска из настроек опаснее отказа | Нет |
| R-71 | Правило «несколько спринтов с именем» — и для `ensure_sprint_state`; `move_open_to` — активный или будущий спринт той же доски, не сам закрываемый, ровно один с этим именем; все проверки — до первой записи; спринт уже в нужном состоянии — `done` без проверки `move_open_to` | Повтор безопасен; ошибка параметров не оставляет спринт полузакрытым | Повтор с опечаткой в `move_open_to` после закрытия — `done`, опечатка не замечена |
| R-72 | Спринт доски без `id`/`name` или с состоянием вне `active`/`future`/`closed` (после `lower()`) — отбрасывается с записью в лог | Хаб отвергает всю пачку (`bad_state`), `refresh` упал бы целиком | Такой спринт не виден в вебе |
| R-73 | Ответ `PUT /issue/rank` с `entries[].status` не 2xx — `failed` «Jira не переранжировала <ключ>: <errors>» | Jira Agile сообщает о частичном отказе кодом 207 | Нет |
| R-74 | Порядок кандидатов полей `(len(id), id)` — числовой порядок `customfield_N`, для всех трёх полей | design агента 1 («первое по id») задан только для оценки, строковое сравнение ставит `customfield_9999` после `customfield_10050` | Не то поле — перекрытие `field_ids` |
| R-75 | Кэш полей и имён статусов — на экземпляр `Executor` (Runner пересоздаёт его при смене настроек) | «Один раз на процесс» с учётом смены `field_ids` | Нет |
---
### Task 1: Поиск полей Jira с кэшем и перекрытием
**Files:**
- Modify: `src/agent.py` — константы `RANK_SCHEMA`, `SPRINT_SCHEMA` у раздела исполнителя; `Executor.__init__` и `Executor.run`
(кэши); `Executor.field_list`, `Executor.jira_fields`; `refresh_history.field_id_of` берёт `self.field_list()`
- Modify: `tests/fake_jira.py` — константы `SPRINT_FIELD = "customfield_10104"`, `RANK_FIELD = "customfield_10105"`,
`POINTS_FIELD = "customfield_10106"`; `self.fields`: у «Sprint» `schema: {"type": "array", "items": "string",
"custom": "com.pyxis.greenhopper.jira:gh-sprint"}`, добавить `{"id": RANK_FIELD, "name": "Rank", "custom": True,
"schema": {"type": "any", "custom": "com.pyxis.greenhopper.jira:gh-lexo-rank"}}` и `{"id": POINTS_FIELD,
"name": "Story Points", "custom": True, "schema": {"type": "number",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:float"}}`
- Test: `tests/test_refresh.py` — новый класс `JiraFieldsTest` (setUp как у `RefreshTest`)
**Interfaces:**
- Produces: `Executor.field_list(self) -> List[dict]` — `GET /rest/api/2/field` один раз на команду (`self._field_list`,
сбрасывается в `run`), только элементы-словари; `HttpError`/`ConnectionError` наружу.
- Produces: `Executor.jira_fields(self) -> Dict[str, Optional[str]]` — ключи `"rank"`, `"sprint"`, `"story_points"` → id
поля или `None`. Все три в `field_ids` — без `GET /field`. Успех — кэш `self._jira_fields` с `self._jira_fields_ok = True`
(живёт, пока жив `Executor`); `HttpError` — `log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % code)`,
ненайденные — `None`, `_jira_fields_ok = False`: кэш живёт до конца команды, `run` его сбрасывает (R-67).
- [ ] **Step 1: Тесты** в `JiraFieldsTest`:
```python
def test_fields_found_by_schema_and_name(self):
want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"}
self.assertEqual(self.ex.jira_fields(), want)
self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив
self.assertEqual(self.ex.jira_fields(), want)
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1)
def test_fields_override_skips_lookup(self):
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p")
self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"})
self.assertEqual(self.fake.requests, [])
def test_fields_partial_override(self):
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000")
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
def test_story_points_first_by_numeric_id_any_case(self):
self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True},
{"id": "customfield_10050", "name": "Story Points", "custom": True}]
self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999")
def test_field_lookup_error_retried_next_command(self):
self.fake.fail_next.append((403, {}))
out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest
self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None})
self.assertTrue(any("HTTP 403" in l for l in logs), logs)
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем
self.ex.run({"id": 2, "type": "nope", "params": {}})
self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105")
```
`test_custom_field_lookup_once` (`tests/test_history.py`) не меняется и должен остаться зелёным.
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`AttributeError: jira_fields`).
- [ ] **Step 3: Реализация** по сигнатурам из Interfaces; `logged` вынести из `RefreshTest` в общий миксин или
повторить в `JiraFieldsTest` (на выбор исполнителя).
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit**
```bash
git add src/agent.py tests/fake_jira.py tests/test_refresh.py
git commit -m "Агент: поиск полей ранга, спринта и оценки с кэшем и перекрытием field_ids (#49)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
### Task 2: `rank`, `story_points`, `sprint_ids` в состоянии `refresh`
**Files:**
- Modify: `src/agent.py` — `Executor.state_fields` (плюс найденные id из `jira_fields()`, без повторов),
`Executor.to_state` (три поля), `Executor.sprint_ids_of`, `Executor.do_refresh` (лог ненайденных полей раз за `refresh`)
- Modify: `tests/fake_jira.py` — `CUSTOM` дополнить `RANK_FIELD`, `POINTS_FIELD`; в `add` база `"sprint_ids": None`;
`issue_json` отдаёт `SPRINT_FIELD`: если у задачи есть ключ `"sprint_raw"` — его как есть; иначе id из
`sprint_ids` (если не `None`), иначе `[id спринта с именем sprint]` из `all_sprints()` (нет имени — `None`); элемент —
`{"id", "name", "state"}` при `self.sprint_field_format == "object"` (по умолчанию) или строка
`"com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s,sequence=%d]"` при `"string"`
- Test: `tests/test_refresh.py`
**Interfaces:**
- Consumes: `jira_fields()` (Task 1).
- Produces: `Executor.sprint_ids_of(self, key: str, value: Any) -> List[int]` — `None`/не список → `[]`; словарь — `id`
(`int` или строка из цифр); строка — `re.search(r"[\[,]id=(\d+)", s)`; иное — пропуск с
`log(" ! %s: элемент поля спринта не разобран" % key)`; повторы убираются.
- Produces: `to_state` кладёт `rank` / `story_points` / `sprint_ids` только для найденных полей (R-64).
- [ ] **Step 1: Тесты** в `RefreshTest`:
```python
def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s}
for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))]
self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0)
self.fake.add("AURORA-2")
self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]),
("0|i0001:", 3.0, [2, 3]))
self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, []))
fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0]
for f in ("customfield_10104", "customfield_10105", "customfield_10106"):
self.assertIn(f, fields)
def test_sprint_field_server_strings(self):
self.fake.add("AURORA-1", sprint_raw=[
"com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]",
"com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]",
"мусор", {"id": "74"}, {"name": "без id"}])
out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"]))
self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74])
self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs)
def test_story_points_non_number_is_null(self):
for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)):
self.fake.add(key, customfield_10106=v)
self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"])
self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5])
def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh
self.fake.fields = []
for i in range(1, 121):
self.fake.add("AURORA-%d" % i, customfield_10106=2)
out, logs = self.logged(lambda: self.refresh(jql=self.JQL))
self.assertEqual(out["result"]["pages"], 2)
for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]:
self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st)
for name in ("rank", "sprint_ids", "story_points"):
self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs)
```
Лог ненайденного поля — `log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (<ранга|спринта|оценки>, <rank|sprint_ids|story_points>))`,
один раз за `refresh`, только если выборка не пуста.
Существующий `test_jira_error_has_no_peer`: первым вызовом Jira теперь стал `GET /field` (его 500 гасится, R-67) —
перед `fail_next.append((500, {}))` добавить `self.ex.jira_fields()`, чтобы 500 получил `search`; проверки теста те же.
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`KeyError: 'rank'`).
- [ ] **Step 3: Реализация** по Interfaces; `story_points` — `v if isinstance(v, (int, float)) and not isinstance(v, bool) else None`;
`rank` — `v if isinstance(v, str) else None`.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: ранг, оценка и все спринты задачи в состоянии refresh (#49)` (+ Co-Authored-By, как в Task 1).
### Task 3: Данные досок в первой пачке `refresh`
**Files:**
- Modify: `src/agent.py` — `Executor.refresh_boards` (новая тройка), `Executor.status_names`, `Executor.board_columns`,
`Executor.kanban_backlog`, `Executor.all_sprints`, `Executor.sprint_state` (статический), `Executor.board_state`,
`Executor.do_refresh` (чтение досок до выборки, `boards` в первой пачке, пустая пачка)
- Modify: `tests/fake_jira.py` — `STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ",
"10003": "Разработка", "10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"}`;
`COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])]`;
`self.board_columns = {}` (доска → список как `COLUMNS`), `self.backlog_codes = {}` (доска → код ответа, по умолчанию 200),
`self.fail_paths = {}` (путь → список кодов; каждый запрос по пути снимает первый и отвечает им, проверяется в `handle`
сразу после `fail_next`); маршруты `GET /rest/api/2/status` (`[{"id", "name"}]` из `STATUSES`),
`GET /rest/agile/1.0/board/{id}/configuration` (`{"id", "columnConfig": {"columns": [{"name", "statuses": [{"id"}]}]}}`,
нет доски — 404), `GET /rest/agile/1.0/board/{id}/backlog` (код из `backlog_codes`; 200 — `{"startAt": 0, "maxResults": 0, "total": 0, "issues": []}`);
`GET /board/{id}/sprint` без параметра `state` — спринты всех состояний (с `state` — как сейчас)
- Test: `tests/test_refresh.py`
**Interfaces:**
- Produces: `refresh_boards(self, p) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]` —
`(scrum, typed, types)`: `typed` — доски из `p["boards"]` с типом `scrum`/`kanban` (известным или определённым), без
повторов, в порядке параметров; без `boards` в `p` — `([board_id настроек] или [], [], None)`.
- Produces: `status_names(self) -> Dict[str, str]` — id → имя, кэш на экземпляр; `HttpError` наружу, не кэшируется.
- Produces: `board_columns(self, board) -> Optional[List[dict]]` — `[{"name", "statuses": [имя]}]` по
`columnConfig.columns`; неизвестный id статуса пропускается; `HttpError` (настройки или статусы) —
`log("доска %s — колонки не прочитаны (HTTP %d)" % (board, code))`, `None`. Используется и в Task 6.
- Produces: `kanban_backlog(self, board) -> Optional[bool]` — `GET /board/{id}/backlog?maxResults=0`: ответ → `True`,
`HttpError` 400 → `False`, иной `HttpError` → `None` с записью в лог.
- Produces: `all_sprints(self, board) -> List[dict]` — `GET /board/{id}/sprint` без `state`, постранично по 50
(`isLast`), кэш на команду `self._all_sprints[str(board)]` (сброс в `run`); при успехе заполняет
`self._sprints[str(board)]` именами активных и будущих (тот же порядок), чтобы `sprints()` не спрашивал Jira повторно;
`HttpError` наружу. Используется в Task 5 и 6.
- Produces: `sprint_state(sp: dict) -> Optional[dict]` — `{"id", "name", "state": state.lower(), "start_date": startDate,
"end_date": endDate, "complete_date": completeDate, "goal"}`; без `id`/`name` или состояние вне трёх — `None` (R-72).
- Produces: `board_state(self, board, kind: str) -> Optional[dict]` — элемент `boards` (Global Constraints): колонки —
`board_columns`; `kanban_backlog` — только у `kanban`, у `scrum` `None`; спринты — у `scrum` `all_sprints`, у `kanban`
`[]`; `HttpError` спринтов — `log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" ...)`,
`None` (R-65); отброшенный спринт — `log("refresh: доска %s — спринт %r пропущен: …" ...)`.
- `do_refresh`: порядок — `refresh_boards` → `boards = [board_state(...)…, без None] if "boards" in p else None` → выборка.
Первая пачка несёт `"boards": boards`, если `boards is not None`; выборка пуста (`keys` пусты или `jql` без задач) и
`boards is not None` — одна пустая пачка, `pages = 1` (R-66); без `boards` в `p` — как v14.
- [ ] **Step 1: Тесты** (`COLS` — модульная константа теста: ожидаемые колонки из `COLUMNS` фейка, имена статусов):
```python
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами»
sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw)
self.fake.board_sprints[4216] = [
sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00",
completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"),
sp(2, "closed"), sp(3, "active"), sp(4, "future")]
for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="S3")
self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3]
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(out["status"], "done", out)
b = self.hub.states[0]["boards"]
self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)])
self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4])
self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed",
"start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00",
"complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"})
self.assertNotIn("boards", self.hub.states[1])
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3"))
sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка
self.assertNotIn("state", sprint_reads[0])
def test_all_sprints_paged(self): ... # 120 спринтов доски → 3 запроса, в boards все 120
def test_kanban_backlog_flag(self):
self.fake.boards[5120] = "kanban"
for code, want in ((200, True), (400, False), (500, None)):
self.fake.backlog_codes[5120] = code
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
b = self.hub.states[-1]["boards"][0]
self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS))
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests])
def test_board_config_error_columns_null(self):
self.fake.add("AURORA-1")
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(out["status"], "done", out)
b = self.hub.states[0]["boards"][0]
self.assertIsNone(b["columns"])
self.assertEqual([s["id"] for s in b["sprints"]], [1, 2])
self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs)
def test_board_sprints_error_board_omitted(self): # R-65
self.fake.add("AURORA-1", sprint="Спринт 2")
self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.hub.states[0]["boards"], [])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_board_bad_sprint_dropped(self): # Review Focus 2
self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"},
{"id": 3, "state": "active"}]
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
def test_unknown_status_id_skipped_and_status_cached(self):
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])]
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}])
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1)
def test_empty_jql_with_boards_sends_empty_batch(self):
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(out["result"]["pages"], 1)
st = self.hub.states[0]
self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True))
self.assertEqual(st["boards"][0]["id"], 4216)
```
Существующие тесты поменять так: `test_empty_keys_still_resolves_boards` — `pages` 0 → 1, плюс
`self.hub.states == [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [], "neighbor_keys": [],
"page": 1, "last_page": True, "boards": [{"id": 5120, "type": "kanban", "columns": COLS, "kanban_backlog": True, "sprints": []}]}]`;
`test_old_hub_without_boards` — добавить `assertNotIn("boards", self.hub.states[0])` и отсутствие запросов
`/configuration`; `test_refresh_empty_keys` и `test_refresh_empty_jql_result` (без `boards`) — без изменений.
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL.
- [ ] **Step 3: Реализация** по Interfaces.
- [ ] **Step 4: Прогон** — весь набор → PASS (в том числе прежние тесты досок: самоисправление канбана R-36 и
«доска 9999» R-32 идут через `sprints()` / `board_sprints`, как раньше).
- [ ] **Step 5: Commit** — `Агент: колонки, kanban-бэклог и все спринты досок в первой пачке refresh (#49)` (+ Co-Authored-By).
### Task 4: `story_points` в `ensure_fields`
**Files:**
- Modify: `src/agent.py` — `Executor.do_ensure_fields`
- Test: `tests/test_executor_fields.py`
**Interfaces:**
- Consumes: `jira_fields()["story_points"]` (Task 1).
- Поведение: `"story_points" in p` — до `GET /issue`: поле не найдено — `ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points")`;
значение не `None` и не число ≥ 0 (`bool` — не число) — `failed` «story_points — число ≥ 0 или null» без вызова Jira
после `GET /field`. Сравнение — Global Constraints; отличие — поле в `PUT`, `"story_points"` в `changed`.
- [ ] **Step 1: Тесты:**
```python
def test_story_points_set_clear_idempotent(self):
self.fake.add("AURORA-1")
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
self.assertEqual(out["result"], {"changed": ["story_points"]})
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3)
for v in (3, 3.0):
again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
self.assertEqual(again["result"], {"changed": []})
self.assertEqual(len(self.fake.writes()), 1)
self.run_cmd("ensure_fields", key="AURORA-1", story_points=None)
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_10106"])
def test_story_points_no_field(self): # сценарий «Нет поля оценки»
self.fake.add("AURORA-1")
self.fake.fields = []
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3)
self.assertEqual((out["status"], out["error"]), ("failed", "поле оценки не найдено в Jira: задайте field_ids.story_points"))
self.assertEqual(self.fake.writes(), [])
def test_story_points_override(self):
self.fake.add("AURORA-1")
self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000")
self.run_cmd("ensure_fields", key="AURORA-1", story_points=5)
self.assertEqual(self.fake.issues["AURORA-1"]["customfield_20000"], 5)
self.assertNotIn(("GET", "/rest/api/2/field"), self.fake.requests)
def test_story_points_bad_value(self):
self.fake.add("AURORA-1")
for v in ("3", -1, True):
out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v)
self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"), v)
self.assertEqual(self.fake.writes(), [])
```
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_fields -q` → FAIL.
- [ ] **Step 3: Реализация.**
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: оценка story_points в ensure_fields (#49)` (+ Co-Authored-By).
### Task 5: Команда `ensure_sprint_meta`
**Files:**
- Modify: `src/agent.py` — модульные `_is_day(value: Any) -> bool`, `_sprint_time(day: str, hour: int) -> str`;
`Executor.sprints_named`, `Executor.sprint_board`, `Executor.do_ensure_sprint_meta`
- Modify: `tests/fake_jira.py` — `self.sprint_seq = 500`, `self.sprint_posts = []` (`(путь, тело)` каждой записи спринта);
`POST /rest/agile/1.0/sprint` — новый спринт `{"id": sprint_seq, "name", "state": "future", "originBoardId", "startDate"?,
"endDate"?, "goal"?}` в `board_sprints[originBoardId]`, если доска там есть, иначе в `self.sprints`; ответ 201 со спринтом;
`POST /rest/agile/1.0/sprint/{id}` — частичная правка (`name`, `startDate`, `endDate`, `goal`, `state`); `state:
"active"` без обеих дат после правки или не из `future`, `state: "closed"` не из `active` — 400; ответ 200 со спринтом
- Modify: `tests/_common.py` — `ZERO_PACING` дополнить `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `fetch_attachment` (0)
- Create: `tests/test_executor_sprints.py` — класс `ExecutorSprintsTest` (setUp как в `test_executor_links.py`: `FakeJira`,
`Executor(..., dict(SETTINGS))`, `self.gaps` — `ex.sleep`; `run_cmd(ctype, **params)`)
**Interfaces:**
- Consumes: `all_sprints(board)` (Task 3).
- Produces: `_is_day(value) -> bool` — строка `ГГГГ-ММ-ДД` (`re.fullmatch(r"\d{4}-\d{2}-\d{2}")` и `datetime.strptime(value, "%Y-%m-%d")`).
- Produces: `_sprint_time(day, hour) -> str` — `"%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z"))` (R-68).
- Produces: `Executor.sprint_board(self, p: dict) -> Any` — `p.get("board_id")`; нет — `ConfigGap("board_id нет в команде — спринты недоступны")` (R-70).
- Produces: `Executor.sprints_named(self, board, name: str) -> List[dict]` — спринты доски из `all_sprints` с точным именем.
- Produces: `Executor.many_sprints(name: str, found: List[dict]) -> str` (статический) — «несколько спринтов с именем «<имя>»: 5, 6».
- `do_ensure_sprint_meta(p, cmd)`: даты проверяются первыми (`failed` «<поле>: дата не в формате ГГГГ-ММ-ДД: <repr>»);
поиск по `sprint`; правила R-69 для `rename_to`; закрытый — «спринт закрыт»; создание — `POST /sprint` `{"name": rename_to or sprint,
"originBoardId": int(board), "startDate"?, "endDate"?, "goal"?}` → `{"sprint_id", "created": True}`; найден — `POST /sprint/{id}` только
с отличающимися `name`/`startDate`/`endDate`/`goal` (`goal` `None` ≡ `""`), нет отличий — без записи → `{"sprint_id", "created": False}`.
- [ ] **Step 1: Тесты:**
```python
B = {"board_id": 4216}
def test_meta_create_then_repeat(self): # сценарий «Создание спринта повтором не дублируется»
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}]
params = dict(B, sprint="AWPCA_Sprint_4", start_date="2026-10-28", end_date="2026-11-11", goal="свод ВГО")
out = self.run_cmd("ensure_sprint_meta", **params)
self.assertEqual(out["status"], "done", out)
sid = out["result"]["sprint_id"]
self.assertEqual(out["result"], {"sprint_id": sid, "created": True})
sp = self.fake.board_sprints[4216][-1]
off = datetime.now().astimezone().strftime("%z")
self.assertEqual((sp["id"], sp["name"], sp["state"], sp["originBoardId"], sp["goal"]), (sid, "AWPCA_Sprint_4", "future", 4216, "свод ВГО"))
self.assertEqual((sp["startDate"], sp["endDate"]), ("2026-10-28T09:00:00.000" + off, "2026-11-11T18:00:00.000" + off))
again = self.run_cmd("ensure_sprint_meta", **params)
self.assertEqual(again["result"], {"sprint_id": sid, "created": False})
self.assertEqual(len(self.fake.writes()), 1)
def test_meta_updates_only_diff(self): # даты совпадают по дню, отличается цель
self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future", "goal": "старая",
"startDate": "2026-10-28T10:00:00.000+03:00", "endDate": "2026-11-11T19:00:00.000+03:00"}]
out = self.run_cmd("ensure_sprint_meta", sprint="S", start_date="2026-10-28", end_date="2026-11-11", goal="новая", **B)
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"goal": "новая"})])
def test_meta_rename_and_name_taken(self): ... # S → T: POST {"name": "T"}; есть S и T — «имя занято», без записи
def test_meta_repeat_after_rename(self): # Review Focus 3, R-69
self.fake.board_sprints[4216] = [{"id": 9, "name": "T", "state": "future"}]
out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B)
self.assertEqual(out["result"], {"sprint_id": 9, "created": False})
self.assertEqual(self.fake.writes(), [])
def test_meta_closed_and_duplicates(self):
self.fake.board_sprints[4216] = [{"id": 1, "name": "C", "state": "closed"},
{"id": 5, "name": "D", "state": "future"}, {"id": 6, "name": "D", "state": "active"}]
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="C", goal="x", **B)["error"], "спринт закрыт")
self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="D", **B)["error"], "несколько спринтов с именем «D»: 5, 6")
self.assertEqual(self.fake.writes(), [])
def test_meta_bad_date(self): # Review Focus 5
for bad in ("28.10.2026", "2026-13-01"):
out = self.run_cmd("ensure_sprint_meta", sprint="N", start_date=bad, **B)
self.assertEqual(out["status"], "failed")
self.assertIn("ГГГГ-ММ-ДД", out["error"])
self.assertEqual(self.fake.writes(), [])
def test_meta_without_board(self):
out = self.run_cmd("ensure_sprint_meta", sprint="N")
self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны"))
self.assertEqual(self.fake.requests, [])
```
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL («неизвестный тип команды»).
- [ ] **Step 3: Реализация** по Interfaces.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_meta — создание и правка спринта (#49)` (+ Co-Authored-By).
### Task 6: Команда `ensure_sprint_state`
**Files:**
- Modify: `src/agent.py` — `Executor.sprint_issues`, `Executor.do_ensure_sprint_state`
- Modify: `tests/fake_jira.py` — `GET /sprint/{id}/issue` без `jql` — все задачи спринта (по `sprint_members` или имени
спринта задачи); каждый элемент `{"key", "fields": {"status": {"name"}}}`; `POST /sprint/{id}/issue` дописывает id в
`sprint_ids` задачи, если тот не `None`
- Test: `tests/test_executor_sprints.py`
**Interfaces:**
- Consumes: `sprint_board`, `sprints_named`, `many_sprints` (Task 5); `all_sprints`, `board_columns` (Task 3); `self.gap("ensure_sprint_state")`.
- Produces: `Executor.sprint_issues(self, sprint_id) -> List[Tuple[str, Optional[str]]]` — `(ключ, статус)` из
`GET /sprint/{id}/issue?fields=status`, постранично по 50 (`total`).
- `do_ensure_sprint_state(p, cmd)` — порядок проверок: `state` не `active`/`closed` — `failed` «state — active или closed»;
доска; спринт (нет — `failed` «нет спринта «<имя>» на доске <N>», несколько — `many_sprints`); уже в нужном
состоянии — `done {"sprint_id", "moved": []}` без записи; `active`: закрытый — «спринт закрыт», нет `startDate` или
`endDate` — «у спринта нет дат: задайте ensure_sprint_meta», иначе `POST /sprint/{id}` `{"state": "active", "startDate", "endDate"}`
(значения спринта); `closed`: будущий — «спринт не начат»; нет `move_open_to` — «move_open_to обязателен при state = closed»;
цель (R-71) — `backlog` или ровно один активный/будущий спринт доски с этим именем, не сам спринт, иначе
`failed` «нет спринта «<имя>» среди активных и будущих доски <N>»; колонки (`board_columns`, `None` или `[]` —
«не удалось прочитать колонки доски»); незавершённые — статус не в последней колонке; переносы по 50
(`POST /sprint/{to}/issue` или `POST /backlog/issue` `{"issues": [...]}`), затем `POST /sprint/{id}` `{"state": "closed"}`;
перед каждой записью, кроме первой, — `self.gap("ensure_sprint_state")` → `{"sprint_id", "moved": [ключи в порядке Jira]}`.
- [ ] **Step 1: Тесты:**
```python
def sprint_with(self, n_done, n_open): # спринт 3 активный, спринт 4 будущий, задачи по имени спринта
self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"},
{"id": 4, "name": "AWPCA_Sprint_4", "state": "future"}]
open_keys = []
for i in range(n_done + n_open):
key = "AURORA-%d" % (2000 + i)
self.fake.add(key, sprint="AWPCA_Sprint_3_Corrections", status="Готово" if i < n_done else "Разработка")
if i >= n_done:
open_keys.append(key)
return open_keys
def test_close_with_move(self): # сценарий «Завершение спринта с переносом»
open_keys = self.sprint_with(25, 12)
params = dict(B, sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="AWPCA_Sprint_4")
out = self.run_cmd("ensure_sprint_state", **params)
self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys})
self.assertEqual({self.fake.issues[k]["sprint"] for k in open_keys}, {"AWPCA_Sprint_4"})
self.assertEqual(self.fake.board_sprints[4216][0]["state"], "closed")
writes = len(self.fake.writes())
again = self.run_cmd("ensure_sprint_state", **params)
self.assertEqual(again["result"], {"sprint_id": 3, "moved": []})
self.assertEqual(len(self.fake.writes()), writes)
def test_close_chunks_and_gap(self):
self.sprint_with(0, 60)
self.ex.s["pacing"] = {"ensure_sprint_state": 30}
self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
self.assertEqual([p for _, p in self.fake.writes()],
["/rest/agile/1.0/backlog/issue"] * 2 + ["/rest/agile/1.0/sprint/3"])
self.assertEqual(self.gaps, [30, 30])
self.assertEqual(self.fake.requests.count(("GET", "/rest/agile/1.0/sprint/3/issue")), 2)
def test_activate_future(self): ... # даты есть → POST {"state": "active", startDate, endDate}; повтор — без записи;
# без дат → «у спринта нет дат: задайте ensure_sprint_meta», writes() == []
def test_state_wrong_transitions(self): ... # closed для future → «спринт не начат»; active для closed → «спринт закрыт»; без записи
def test_state_bad_move_target_no_writes(self): # Review Focus 4
self.sprint_with(1, 1)
self.fake.board_sprints[4216].append({"id": 1, "name": "Старый", "state": "closed"})
for target in ("Нет такого", "Старый", "AWPCA_Sprint_3_Corrections"):
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to=target, **B)
self.assertEqual(out["status"], "failed", target)
self.assertEqual(self.fake.writes(), [])
def test_close_columns_unreadable(self):
self.sprint_with(1, 1)
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B)
self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски"))
self.assertEqual(self.fake.writes(), [])
```
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL.
- [ ] **Step 3: Реализация** по Interfaces.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_state — запуск и завершение спринта с переносом (#49)` (+ Co-Authored-By).
### Task 7: Команда `ensure_rank`
**Files:**
- Modify: `src/agent.py` — `Executor.do_ensure_rank`
- Modify: `tests/fake_jira.py` — `PUT /rest/agile/1.0/issue/rank`: тело в `self.rank_bodies`; задачи с ключом в
`self.rank_errors` (ключ → текст) — ответ 207 `{"entries": [{"issueKey", "issueId", "status": 400, "errors": [текст]}]}`
без изменения; иначе ранг `RANK_FIELD` задачи становится строго меньше (`rankBeforeIssue`) или больше
(`rankAfterIssue`) ранга другой задачи (например, `before[:-1] + chr(ord(before[-1]) - 1) + "z"` и `after + "m"`), ответ 204
- Test: `tests/test_executor_sprints.py`
**Interfaces:**
- Consumes: `jira_fields()["rank"]` (Task 1).
- `do_ensure_rank(p, cmd)`: ровно одно из `before`/`after` — иначе `failed` «нужно ровно одно из before, after»; другая
задача — та же — `failed` «задача не может стоять выше или ниже самой себя»; поле ранга не найдено —
`failed` «поле ранга не найдено»; ранги — один `GET /search` `jql="key in (<key>,<other>)"`, `fields=<id ранга>`;
`before` и `rank(key) < rank(other)` или `after` и `>` (оба — строки) — `done {"changed": False}`; иначе
`PUT /rest/agile/1.0/issue/rank` `{"issues": [key], "rankBeforeIssue" | "rankAfterIssue": other, "rankCustomFieldId": <число из id>}`
(id без цифрового хвоста — без `rankCustomFieldId`); `entries` с `status` не 2xx — `failed`
«Jira не переранжировала <ключ>: <errors через «; »>» (R-73); иначе `done {"changed": True}`.
- [ ] **Step 1: Тесты:**
```python
def test_rank_already(self): # сценарий «Ранг уже такой»
self.fake.add("AURORA-1612", customfield_10105="0|i0001:")
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
self.assertEqual((out["status"], out["result"]), ("done", {"changed": False}))
self.assertEqual(self.fake.writes(), [])
def test_rank_before_and_after_write(self):
self.fake.add("AURORA-1612", customfield_10105="0|i0003:")
self.fake.add("AURORA-1601", customfield_10105="0|i0002:")
out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")
self.assertEqual(out["result"], {"changed": True})
self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105})
self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")["result"], {"changed": False})
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", after="AURORA-1601")["result"], {"changed": True})
self.assertGreater(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"])
def test_rank_null_rank_writes(self): ... # у key ранга нет (None) → PUT, changed True
def test_rank_bad_params(self): ... # before и after вместе, ни одного, key == before → failed, без записи
def test_rank_no_field(self):
self.fake.add("AURORA-1"); self.fake.add("AURORA-2")
self.fake.fields = []
self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1", after="AURORA-2")["error"], "поле ранга не найдено")
def test_rank_207_error(self): # Review Focus 1
self.fake.add("AURORA-1", customfield_10105="0|i0009:"); self.fake.add("AURORA-2", customfield_10105="0|i0001:")
self.fake.rank_errors["AURORA-1"] = "Ранжирование недоступно"
out = self.run_cmd("ensure_rank", key="AURORA-1", before="AURORA-2")
self.assertEqual((out["status"], out["error"]), ("failed", "Jira не переранжировала AURORA-1: Ранжирование недоступно"))
```
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL.
- [ ] **Step 3: Реализация** по Interfaces.
- [ ] **Step 4: Прогон** — весь набор → PASS.
- [ ] **Step 5: Commit** — `Агент: команда ensure_rank — выше или ниже другой задачи (#49)` (+ Co-Authored-By).
### Task 8: Версия 15 и документация
**Files:**
- Modify: `src/agent.py` — `AGENT_VERSION = 15`
- Modify: `README.md` — таблица «Что делает агент с командами»: строки `ensure_sprint_meta`, `ensure_sprint_state`,
`ensure_rank`; в `ensure_fields` — `story_points`; в `refresh` — `rank`, `story_points`, `sprint_ids`, данные досок
(`boards` в первой пачке, пустая пачка); абзац «Версия»: «сейчас 15» и «Версия 15 — …» (поиск полей по `GET /field`,
перекрытие `field_ids.rank`/`sprint`/`story_points`, проверка на Jira банка при выкатке; агент v14 на новые типы отвечает
«неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки)
- Modify: `ARCHITECTURE.md` — строка «Исполнитель»: `jira_fields`/`field_list`, `board_state`, `all_sprints`, новые `do_<тип>`;
«Открытые вопросы»: снять «Спринт задач закрытых спринтов: `sprint = null`» (история — `sprint_ids`), оставить про `sprint`
- Modify: `CLAUDE.md` — «Последний change» → `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг,
оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — этот файл; в «Тесты» —
`test_executor_sprints.py` (`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`)
- Modify: `openspec/changes/agent-v15-sprints/design.md` — в таблицу «Decisions made without discussion» строки R-64…R-75 из
раздела «Rulings при планировании» (дата 2026-10-24) и решения исполнителя, если были
- Test: `tests/test_refresh.py`
- [ ] **Step 1: Тест** — `test_agent_version_15`: `self.assertEqual(agent.AGENT_VERSION, 15)`; после `refresh` с одной задачей
`self.hub.states[0]["agent_version"] == 15`.
- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (14 != 15).
- [ ] **Step 3: Реализация** — версия и документы по списку Files.
- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS; `/usr/bin/python3 -m py_compile src/agent.py` без вывода.
- [ ] **Step 5: Commit** — `Агент v15: версия и документация спринтов, ранга и оценок (#49)` (+ Co-Authored-By).