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

58 KiB
Raw Permalink Blame History

Спринты, ранг и оценки (агент 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»; «не удалось прочитать колонки доски»; «несколько спринтов с именем «<имя>»: , ».
  • Числа оценок равны, если 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 не переранжировала <ключ>: » 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:

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
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:

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 фейка, имена статусов):

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: Тесты:

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 «<поле>: дата не в формате ГГГГ-ММ-ДД: »); поиск по 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: Тесты:

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 «нет спринта «<имя>» на доске », несколько — 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 «нет спринта «<имя>» среди активных и будущих доски »; колонки (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: Тесты:

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: Тесты:

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).