jira-agent/docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md
Andrey Epifancev 0edea89133 План: агент v15, спринты (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 18:21:28 +04:00

674 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Спринты, ранг и оценки (агент 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).