58 KiB
Спринты, ранг и оценки (агент 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
PUT /rest/agile/1.0/issue/rankответил 207 Multi-Status с ошибкой по задаче (Jira Agile так сообщает о частичном отказе) —failedс текстом Jira, а не{changed: true}(тестtest_rank_207_errorв Task 7).- Спринт доски с состоянием вне
active/future/closedили безid/name— хаб отверг бы всю пачку (bad_state); агент отбрасывает такой спринт с записью в лог, остальное уходит (тестtest_board_bad_sprint_droppedв Task 3). - Повтор
ensure_sprint_meta {sprint: "S", rename_to: "T"}после успешного переименования — спринта «S» уже нет, есть «T»:doneбез записи, а не второй спринт и не «имя занято» (тестtest_meta_repeat_after_renameв Task 5). ensure_sprint_state closedсmove_open_to— неизвестное имя, закрытый спринт или тот же спринт —failedдо первой записи, Jira не изменена (тестtest_state_bad_move_target_no_writesв Task 6).- Дата не в формате
ГГГГ-ММ-ДД("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,HttpError400 →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, уscrumNone; спринты — уscrumall_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(goalNone≡""), нет отличий — без записи →{"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 /searchjql="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).