From 9eddc13b62a519d248acb52af0a58565de91bc7d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:09:20 +0400 Subject: [PATCH 01/19] OpenSpec: design-only change agent-v15-sprints (#49) Co-Authored-By: Claude Opus 5.5 --- .../changes/agent-v15-sprints/.openspec.yaml | 2 + openspec/changes/agent-v15-sprints/design.md | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 openspec/changes/agent-v15-sprints/.openspec.yaml create mode 100644 openspec/changes/agent-v15-sprints/design.md diff --git a/openspec/changes/agent-v15-sprints/.openspec.yaml b/openspec/changes/agent-v15-sprints/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/agent-v15-sprints/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md new file mode 100644 index 0000000..31b4c1c --- /dev/null +++ b/openspec/changes/agent-v15-sprints/design.md @@ -0,0 +1,61 @@ +# agent-v15-sprints — решения агента + +Спека и дизайн изменения — в `sdlc-hub/openspec/changes/sprints-backlog/` (issue aep/sdlc-hub#108, aep/jira-agent#49). +Агентская часть: требование `agent` «Спринты, ранг и оценки» (ADDED); контракт команд — `commands` «Команда — +желаемый результат на одной задаче» (MODIFIED: `story_points` в `ensure_fields`, `ensure_sprint_meta`, +`ensure_sprint_state`, `ensure_rank`) и абзацы о командах спринтов; формат состояния — `hub-api` «Приём досок и +спринтов» (ADDED). Версия агента — 15. + +## Решения агента + +1. **Поля Jira — по типу поля, один раз на процесс.** `GET /rest/api/2/field` при первой нужде, кэш на процесс: + - ранг — поле с `schema.custom = "com.pyxis.greenhopper.jira:gh-lexo-rank"`; + - спринт — поле с `schema.custom = "com.pyxis.greenhopper.jira:gh-sprint"`; + - story points — поле с именем «Story Points» (без учёта регистра); несколько — первое по id. + Перекрытие из настроек хаба: `field_ids.rank`, `field_ids.sprint`, `field_ids.story_points` (если заданы — поиск не + нужен). Ошибка `GET /field` — поля считаются ненайденными, причина в лог; повтор — в следующем `refresh`. +2. **`sprint_ids` — из поля спринта в том же `search`**, а не обходом спринтов: id поля спринта добавляется в `fields` + поиска. Значение — список; элемент — объект с `id` (Jira 8+) или строка + `com.atlassian.greenhopper.service.sprint.Sprint@…[id=72,…]` (Jira Server): id — из `id` или из `id=` в строке; + не разобралось — элемент пропускается с записью в лог. Поля нет — `sprint_ids` не передаётся (хаб сохранит + прежнее). Имя текущего спринта (`sprint`) — как в v12 (обход активных и будущих спринтов досок), без изменений. +3. **`rank`, `story_points` — тоже из `search`**: id полей добавляются в `fields`. `story_points` — число (`int` или + `float`), иное — `null`; поле не найдено — `story_points` не передаётся у задач и запись в лог один раз. +4. **Данные досок.** Для каждой доски `scrum`/`kanban` из `refresh.boards` (тип известный или только что определённый): + - колонки — `GET /rest/agile/1.0/board/{id}/configuration` → `columnConfig.columns[]` `{name, statuses: [{id}]}`; + id статусов переводятся в имена по `GET /rest/api/2/status` (кэш на процесс; неизвестный id — пропускается); + - kanban-бэклог (только `kanban`): `GET /rest/agile/1.0/board/{id}/backlog?maxResults=0` — 200 → `true`, 400 → `false`, + иное → `null`; проверить на Jira банка при выкатке; + - спринты (только `scrum`): `GET /rest/agile/1.0/board/{id}/sprint` без фильтра состояния, постранично (50); + элемент → `{id, name, state, start_date: startDate, end_date: endDate, complete_date: completeDate, goal}`; + - ошибка чтения настроек доски — `columns: null`, `kanban_backlog: null`, спринты — если прочитались; причина в лог. + `boards` прикладывается к первой пачке `POST /agent/state`; при пустой выборке — одна пачка `{issues: [], links: [], + boards}`. Доски без данных (тип `null`) в `boards` не входят. +5. **Даты команд спринта.** `start_date`/`end_date` (`ГГГГ-ММ-ДД`) → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` и + `ГГГГ-ММ-ДДT18:00:00.000<смещение>`, смещение — локального времени агента (`time.strftime("%z")`, вид `+0300`). + Сравнение с Jira — по дате (первые 10 символов `startDate`/`endDate`). +6. **`ensure_sprint_meta`.** Спринты доски `board_id` — все состояния (тот же обход, что в п. 4, кэш на команду); + поиск по имени `sprint` (точное совпадение). Нет — `POST /rest/agile/1.0/sprint` `{name: rename_to or sprint, + originBoardId, startDate?, endDate?, goal?}`; есть — `POST /rest/agile/1.0/sprint/{id}` только с отличающимися + полями (`name` при `rename_to`, даты по п. 5, `goal`); нет отличий — `done` без записи. Ошибки — по контракту + `commands`. +7. **`ensure_sprint_state`.** `active`: спринт `future` — `POST /sprint/{id}` `{state: "active", startDate, endDate}` + (из спринта; нет дат — `failed`). `closed`: колонки доски (п. 4) → набор статусов последней колонки; задачи спринта + (`GET /sprint/{id}/issue?fields=status`, постранично) не в этих статусах — в `move_open_to` (`POST /sprint/{to}/issue` + или `POST /backlog/issue`, по 50 ключей), затем `POST /sprint/{id}` `{state: "closed"}`. Колонки не прочитались — + `failed` «не удалось прочитать колонки доски». Между записями — зазор типа команды, как в `ensure_link`. +8. **`ensure_rank`.** Ранг обеих задач — `search` по `key in (key, other)` с полем ранга; нет поля ранга — `failed` + «поле ранга не найдено»; уже выше/ниже (сравнение строк) — `done {changed: false}`; иначе + `PUT /rest/agile/1.0/issue/rank` `{issues: [key], rankBeforeIssue | rankAfterIssue: other, rankCustomFieldId}` → + `{changed: true}`. +9. **`story_points` в `ensure_fields`.** Поле по п. 1; не найдено — `failed` «поле оценки не найдено в Jira: задайте + field_ids.story_points». Сравнение чисел: равны, если `abs(a - b) < 1e-9`; `null` — очистка (`None` в `PUT`). + +## Decisions made without discussion + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-60 | 2026-10-24 | Поля ранга, спринта, оценки — по типу/имени через `GET /field`, перекрытие `field_ids` (п. 1) | Владелец полей не знает, оценок в AURORA ещё нет | Не то поле «Story Points» — задать `field_ids.story_points` | +| R-61 | 2026-10-24 | `sprint_ids` из поля спринта в `search`, разбор строкового вида Jira Server (п. 2) | Обход закрытых спринтов — N запросов на спринт; поле есть у каждой задачи | Формат поля банка иной — `sprint_ids` пропадут, видно на живой проверке | +| R-62 | 2026-10-24 | Kanban-бэклог — по ответу `GET /board/{id}/backlog` (п. 4) | В `configuration` Jira Server явного признака нет | `null` — у kanban нет раздела «Бэклог» | +| R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками | -- 2.47.2 From 0edea89133bd346dcf4202385d468f2979357cfd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:21:28 +0400 Subject: [PATCH 02/19] =?UTF-8?q?=D0=9F=D0=BB=D0=B0=D0=BD:=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=20v15,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-24-sprints-backlog-agent.md | 673 ++++++++++++++++++ 1 file changed, 673 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md diff --git a/docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md b/docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md new file mode 100644 index 0000000..d876d83 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md @@ -0,0 +1,673 @@ +# Спринты, ранг и оценки (агент v15) — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** агент v15 в каждом `refresh` присылает хабу ранг, оценку и все спринты задач, данные досок (колонки, +kanban-бэклог, все спринты scrum-доски) и выполняет новые команды `ensure_sprint_meta`, `ensure_sprint_state`, +`ensure_rank` и поле `story_points` в `ensure_fields`. + +**Architecture:** всё в `src/agent.py`, класс `Executor`. Поля Jira (ранг, спринт, оценка) ищутся по `GET /rest/api/2/field` +(кэш на экземпляр `Executor`, перекрытие `field_ids`) и добавляются в `fields` поиска `refresh`; данные досок читаются +после определения типов досок (`refresh_boards`) и уходят с первой пачкой `POST /agent/state`. Новые команды — методы +`do_<тип>` по образцу `do_ensure_sprint` / `do_ensure_link` («проверь, потом сделай», `self.gap(<тип>)` между записями). + +**Tech Stack:** Python 3.9 (`/usr/bin/python3`), только stdlib, `unittest`, фейки `tests/fake_jira.py`, `tests/fake_hub.py`. + +**Spec:** `../sdlc-hub/openspec/changes/sprints-backlog/` (полный путь +`/Users/andrey.epifancev/projects/jira-bridge/sdlc-hub/.wt/sprints/openspec/changes/sprints-backlog/`: `design.md` +решения 10–13, 18–20, 24 и «Контракт данных»; `specs/agent/spec.md` «Спринты, ранг и оценки»; `specs/commands/spec.md` +таблица типов и абзацы о командах спринтов; `specs/hub-api/spec.md` «Приём досок и спринтов») и +`openspec/changes/agent-v15-sprints/design.md` этого репозитория (решения 1–9, R-60…R-63). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/v15-sprints`, ветка `feat/49-v15-sprints` +(aep/jira-agent#49). Все команды — из корня worktree. Хабовая часть — отдельный план в `sdlc-hub`. + +## Global Constraints + +- `AGENT_VERSION = 15`. +- Python 3.9.6, только stdlib; без `match`, без `X | Y` в аннотациях функций (`tests/test_py39.py`). +- Поиск полей (design агента 1): ранг — `schema.custom == "com.pyxis.greenhopper.jira:gh-lexo-rank"`; спринт — + `schema.custom == "com.pyxis.greenhopper.jira:gh-sprint"`; оценка — имя «Story Points» без учёта регистра и пробелов + по краям; несколько кандидатов — первый по ключу `(len(id), id)`. Перекрытие — `field_ids.rank`, `field_ids.sprint`, + `field_ids.story_points` настроек хаба. +- Поля задачи в состоянии: `rank` (строка или `None`), `story_points` (`int`/`float`, не `bool`, иначе `None`), + `sprint_ids` (список целых, без повторов, порядок Jira). Поле не найдено — ключа у задач нет (R-64). +- Элемент `boards`: `{"id", "type", "columns": [{"name", "statuses": [имя статуса]}] | None, "kanban_backlog": True | False | None, + "sprints": [{"id", "name", "state", "start_date", "end_date", "complete_date", "goal"}]}`; даты — строки Jira как есть. + `boards` — только в первой пачке; при пустой выборке — одна пачка `{issues: [], links: [], neighbor_keys: [], page: 1, last_page: True, boards}`. +- Даты команд: `ГГГГ-ММ-ДД` → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` (начало) и `ГГГГ-ММ-ДДT18:00:00.000<смещение>` (конец), + смещение — `datetime.now().astimezone().strftime("%z")` (вид `+0300`, R-68); сравнение с Jira — `_date()` (первые 10 символов). +- Результаты: `ensure_sprint_meta` — `{"sprint_id", "created"}`; `ensure_sprint_state` — `{"sprint_id", "moved": [ключ…]}`; + `ensure_rank` — `{"changed"}`; `ensure_fields` — как раньше, `"story_points"` в `changed`. +- Тексты ошибок (точно): «поле оценки не найдено в Jira: задайте field_ids.story_points»; «поле ранга не найдено»; + «спринт закрыт»; «спринт не начат»; «имя занято»; «у спринта нет дат: задайте ensure_sprint_meta»; + «не удалось прочитать колонки доски»; «несколько спринтов с именем «<имя>»: , ». +- Числа оценок равны, если `abs(a - b) < 1e-9`; ранги сравниваются как строки. +- Каждый коммит — русский заголовок с `(#49)` в конце первой строки и последней строкой + `Co-Authored-By: Claude Opus 5.5 `; комментарии и docstring — по-русски. +- Зелёный — весь набор: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +- Решения исполнителя вне плана — в таблицу `openspec/changes/agent-v15-sprints/design.md` (следующий свободный `R-n`); + находки ревью задачи n — `T.`. + +## 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`: + +```python +def test_fields_found_by_schema_and_name(self): + want = {"rank": "customfield_10105", "sprint": "customfield_10104", "story_points": "customfield_10106"} + self.assertEqual(self.ex.jira_fields(), want) + self.ex.run({"id": 2, "type": "nope", "params": {}}) # новая команда — кэш процесса жив + self.assertEqual(self.ex.jira_fields(), want) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) + +def test_fields_override_skips_lookup(self): + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], rank="cf_r", sprint="cf_s", story_points="cf_p") + self.assertEqual(self.ex.jira_fields(), {"rank": "cf_r", "sprint": "cf_s", "story_points": "cf_p"}) + self.assertEqual(self.fake.requests, []) + +def test_fields_partial_override(self): + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000") + self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_20000") + self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105") + +def test_story_points_first_by_numeric_id_any_case(self): + self.fake.fields += [{"id": "customfield_9999", "name": " story points ", "custom": True}, + {"id": "customfield_10050", "name": "Story Points", "custom": True}] + self.assertEqual(self.ex.jira_fields()["story_points"], "customfield_9999") + +def test_field_lookup_error_retried_next_command(self): + self.fake.fail_next.append((403, {})) + out, logs = self.logged(lambda: (self.ex.jira_fields(), self.ex.jira_fields())) # logged — как в RefreshTest + self.assertEqual(out[0], {"rank": None, "sprint": None, "story_points": None}) + self.assertTrue(any("HTTP 403" in l for l in logs), logs) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/field")), 1) # в той же команде — не повторяем + self.ex.run({"id": 2, "type": "nope", "params": {}}) + self.assertEqual(self.ex.jira_fields()["rank"], "customfield_10105") +``` + +`test_custom_field_lookup_once` (`tests/test_history.py`) не меняется и должен остаться зелёным. + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`AttributeError: jira_fields`). +- [ ] **Step 3: Реализация** по сигнатурам из Interfaces; `logged` вынести из `RefreshTest` в общий миксин или + повторить в `JiraFieldsTest` (на выбор исполнителя). +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** + +```bash +git add src/agent.py tests/fake_jira.py tests/test_refresh.py +git commit -m "Агент: поиск полей ранга, спринта и оценки с кэшем и перекрытием field_ids (#49)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +### Task 2: `rank`, `story_points`, `sprint_ids` в состоянии `refresh` + +**Files:** +- Modify: `src/agent.py` — `Executor.state_fields` (плюс найденные id из `jira_fields()`, без повторов), + `Executor.to_state` (три поля), `Executor.sprint_ids_of`, `Executor.do_refresh` (лог ненайденных полей раз за `refresh`) +- Modify: `tests/fake_jira.py` — `CUSTOM` дополнить `RANK_FIELD`, `POINTS_FIELD`; в `add` база `"sprint_ids": None`; + `issue_json` отдаёт `SPRINT_FIELD`: если у задачи есть ключ `"sprint_raw"` — его как есть; иначе id из + `sprint_ids` (если не `None`), иначе `[id спринта с именем sprint]` из `all_sprints()` (нет имени — `None`); элемент — + `{"id", "name", "state"}` при `self.sprint_field_format == "object"` (по умолчанию) или строка + `"com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s,sequence=%d]"` при `"string"` +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Consumes: `jira_fields()` (Task 1). +- Produces: `Executor.sprint_ids_of(self, key: str, value: Any) -> List[int]` — `None`/не список → `[]`; словарь — `id` + (`int` или строка из цифр); строка — `re.search(r"[\[,]id=(\d+)", s)`; иное — пропуск с + `log(" ! %s: элемент поля спринта не разобран" % key)`; повторы убираются. +- Produces: `to_state` кладёт `rank` / `story_points` / `sprint_ids` только для найденных полей (R-64). + +- [ ] **Step 1: Тесты** в `RefreshTest`: + +```python +def test_state_rank_points_sprint_ids(self): # сценарий «Доска с закрытыми спринтами», часть задач + self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": s} + for i, s in ((1, "closed"), (2, "closed"), (3, "active"), (4, "future"))] + self.fake.add("AURORA-1", sprint="S3", sprint_ids=[2, 3], customfield_10105="0|i0001:", customfield_10106=3.0) + self.fake.add("AURORA-2") + self.ex.run({"id": 5, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}}) + by = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertEqual((by["AURORA-1"]["rank"], by["AURORA-1"]["story_points"], by["AURORA-1"]["sprint_ids"]), + ("0|i0001:", 3.0, [2, 3])) + self.assertEqual((by["AURORA-2"]["rank"], by["AURORA-2"]["story_points"], by["AURORA-2"]["sprint_ids"]), (None, None, [])) + fields = [q["fields"].split(",") for q in self.fake.queries if "jql" in q and "fields" in q][0] + for f in ("customfield_10104", "customfield_10105", "customfield_10106"): + self.assertIn(f, fields) + +def test_sprint_field_server_strings(self): + self.fake.add("AURORA-1", sprint_raw=[ + "com.atlassian.greenhopper.service.sprint.Sprint@1a[id=72,rapidViewId=4216,state=CLOSED,name=A,sequence=72]", + "com.atlassian.greenhopper.service.sprint.Sprint@1b[id=73,rapidViewId=4216,state=ACTIVE,name=B,sequence=73]", + "мусор", {"id": "74"}, {"name": "без id"}]) + out, logs = self.logged(lambda: self.refresh(keys=["AURORA-1"])) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint_ids"], [72, 73, 74]) + self.assertEqual(len([l for l in logs if "AURORA-1" in l and "не разобран" in l]), 2, logs) + +def test_story_points_non_number_is_null(self): + for key, v in (("AURORA-1", "3"), ("AURORA-2", True), ("AURORA-3", 5)): + self.fake.add(key, customfield_10106=v) + self.refresh(keys=["AURORA-1", "AURORA-2", "AURORA-3"]) + self.assertEqual([i["story_points"] for i in self.hub.states[0]["issues"]], [None, None, 5]) + +def test_missing_fields_not_sent_logged_once(self): # сценарий «Нет поля оценки», сторона refresh + self.fake.fields = [] + for i in range(1, 121): + self.fake.add("AURORA-%d" % i, customfield_10106=2) + out, logs = self.logged(lambda: self.refresh(jql=self.JQL)) + self.assertEqual(out["result"]["pages"], 2) + for st in self.hub.states[0]["issues"] + self.hub.states[1]["issues"]: + self.assertFalse({"rank", "story_points", "sprint_ids"} & set(st), st) + for name in ("rank", "sprint_ids", "story_points"): + self.assertEqual(len([l for l in logs if "не найдено" in l and name in l]), 1, logs) +``` + +Лог ненайденного поля — `log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (<ранга|спринта|оценки>, ))`, +один раз за `refresh`, только если выборка не пуста. + +Существующий `test_jira_error_has_no_peer`: первым вызовом Jira теперь стал `GET /field` (его 500 гасится, R-67) — +перед `fail_next.append((500, {}))` добавить `self.ex.jira_fields()`, чтобы 500 получил `search`; проверки теста те же. + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (`KeyError: 'rank'`). +- [ ] **Step 3: Реализация** по Interfaces; `story_points` — `v if isinstance(v, (int, float)) and not isinstance(v, bool) else None`; + `rank` — `v if isinstance(v, str) else None`. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: ранг, оценка и все спринты задачи в состоянии refresh (#49)` (+ Co-Authored-By, как в Task 1). + +### Task 3: Данные досок в первой пачке `refresh` + +**Files:** +- Modify: `src/agent.py` — `Executor.refresh_boards` (новая тройка), `Executor.status_names`, `Executor.board_columns`, + `Executor.kanban_backlog`, `Executor.all_sprints`, `Executor.sprint_state` (статический), `Executor.board_state`, + `Executor.do_refresh` (чтение досок до выборки, `boards` в первой пачке, пустая пачка) +- Modify: `tests/fake_jira.py` — `STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ", + "10003": "Разработка", "10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"}`; + `COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])]`; + `self.board_columns = {}` (доска → список как `COLUMNS`), `self.backlog_codes = {}` (доска → код ответа, по умолчанию 200), + `self.fail_paths = {}` (путь → список кодов; каждый запрос по пути снимает первый и отвечает им, проверяется в `handle` + сразу после `fail_next`); маршруты `GET /rest/api/2/status` (`[{"id", "name"}]` из `STATUSES`), + `GET /rest/agile/1.0/board/{id}/configuration` (`{"id", "columnConfig": {"columns": [{"name", "statuses": [{"id"}]}]}}`, + нет доски — 404), `GET /rest/agile/1.0/board/{id}/backlog` (код из `backlog_codes`; 200 — `{"startAt": 0, "maxResults": 0, "total": 0, "issues": []}`); + `GET /board/{id}/sprint` без параметра `state` — спринты всех состояний (с `state` — как сейчас) +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Produces: `refresh_boards(self, p) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]` — + `(scrum, typed, types)`: `typed` — доски из `p["boards"]` с типом `scrum`/`kanban` (известным или определённым), без + повторов, в порядке параметров; без `boards` в `p` — `([board_id настроек] или [], [], None)`. +- Produces: `status_names(self) -> Dict[str, str]` — id → имя, кэш на экземпляр; `HttpError` наружу, не кэшируется. +- Produces: `board_columns(self, board) -> Optional[List[dict]]` — `[{"name", "statuses": [имя]}]` по + `columnConfig.columns`; неизвестный id статуса пропускается; `HttpError` (настройки или статусы) — + `log("доска %s — колонки не прочитаны (HTTP %d)" % (board, code))`, `None`. Используется и в Task 6. +- Produces: `kanban_backlog(self, board) -> Optional[bool]` — `GET /board/{id}/backlog?maxResults=0`: ответ → `True`, + `HttpError` 400 → `False`, иной `HttpError` → `None` с записью в лог. +- Produces: `all_sprints(self, board) -> List[dict]` — `GET /board/{id}/sprint` без `state`, постранично по 50 + (`isLast`), кэш на команду `self._all_sprints[str(board)]` (сброс в `run`); при успехе заполняет + `self._sprints[str(board)]` именами активных и будущих (тот же порядок), чтобы `sprints()` не спрашивал Jira повторно; + `HttpError` наружу. Используется в Task 5 и 6. +- Produces: `sprint_state(sp: dict) -> Optional[dict]` — `{"id", "name", "state": state.lower(), "start_date": startDate, + "end_date": endDate, "complete_date": completeDate, "goal"}`; без `id`/`name` или состояние вне трёх — `None` (R-72). +- Produces: `board_state(self, board, kind: str) -> Optional[dict]` — элемент `boards` (Global Constraints): колонки — + `board_columns`; `kanban_backlog` — только у `kanban`, у `scrum` `None`; спринты — у `scrum` `all_sprints`, у `kanban` + `[]`; `HttpError` спринтов — `log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" ...)`, + `None` (R-65); отброшенный спринт — `log("refresh: доска %s — спринт %r пропущен: …" ...)`. +- `do_refresh`: порядок — `refresh_boards` → `boards = [board_state(...)…, без None] if "boards" in p else None` → выборка. + Первая пачка несёт `"boards": boards`, если `boards is not None`; выборка пуста (`keys` пусты или `jql` без задач) и + `boards is not None` — одна пустая пачка, `pages = 1` (R-66); без `boards` в `p` — как v14. + +- [ ] **Step 1: Тесты** (`COLS` — модульная константа теста: ожидаемые колонки из `COLUMNS` фейка, имена статусов): + +```python +COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]}, + {"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]}, + {"name": "Готово", "statuses": ["Готово", "Снято"]}] + +def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами» + sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw) + self.fake.board_sprints[4216] = [ + sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00", + completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"), + sp(2, "closed"), sp(3, "active"), sp(4, "future")] + for i in range(1, 121): + self.fake.add("AURORA-%d" % i, sprint="S3") + self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3] + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(out["status"], "done", out) + b = self.hub.states[0]["boards"] + self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)]) + self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4]) + self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed", + "start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00", + "complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"}) + self.assertNotIn("boards", self.hub.states[1]) + by = {i["key"]: i for i in self.hub.states[0]["issues"]} + self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3")) + sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"] + self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка + self.assertNotIn("state", sprint_reads[0]) + +def test_all_sprints_paged(self): ... # 120 спринтов доски → 3 запроса, в boards все 120 + +def test_kanban_backlog_flag(self): + self.fake.boards[5120] = "kanban" + for code, want in ((200, True), (400, False), (500, None)): + self.fake.backlog_codes[5120] = code + self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}]) + b = self.hub.states[-1]["boards"][0] + self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS)) + self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests]) + +def test_board_config_error_columns_null(self): + self.fake.add("AURORA-1") + self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403] + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + b = self.hub.states[0]["boards"][0] + self.assertIsNone(b["columns"]) + self.assertEqual([s["id"] for s in b["sprints"]], [1, 2]) + self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs) + +def test_board_sprints_error_board_omitted(self): # R-65 + self.fake.add("AURORA-1", sprint="Спринт 2") + self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.hub.states[0]["boards"], []) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + +def test_board_bad_sprint_dropped(self): # Review Focus 2 + self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"}, + {"id": 3, "state": "active"}] + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")]) + self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs) + +def test_unknown_status_id_skipped_and_status_cached(self): + self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])] + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1) + +def test_empty_jql_with_boards_sends_empty_batch(self): + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertEqual(out["result"]["pages"], 1) + st = self.hub.states[0] + self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True)) + self.assertEqual(st["boards"][0]["id"], 4216) +``` + +Существующие тесты поменять так: `test_empty_keys_still_resolves_boards` — `pages` 0 → 1, плюс +`self.hub.states == [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [], "neighbor_keys": [], +"page": 1, "last_page": True, "boards": [{"id": 5120, "type": "kanban", "columns": COLS, "kanban_backlog": True, "sprints": []}]}]`; +`test_old_hub_without_boards` — добавить `assertNotIn("boards", self.hub.states[0])` и отсутствие запросов +`/configuration`; `test_refresh_empty_keys` и `test_refresh_empty_jql_result` (без `boards`) — без изменений. + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL. +- [ ] **Step 3: Реализация** по Interfaces. +- [ ] **Step 4: Прогон** — весь набор → PASS (в том числе прежние тесты досок: самоисправление канбана R-36 и + «доска 9999» R-32 идут через `sprints()` / `board_sprints`, как раньше). +- [ ] **Step 5: Commit** — `Агент: колонки, kanban-бэклог и все спринты досок в первой пачке refresh (#49)` (+ Co-Authored-By). + +### Task 4: `story_points` в `ensure_fields` + +**Files:** +- Modify: `src/agent.py` — `Executor.do_ensure_fields` +- Test: `tests/test_executor_fields.py` + +**Interfaces:** +- Consumes: `jira_fields()["story_points"]` (Task 1). +- Поведение: `"story_points" in p` — до `GET /issue`: поле не найдено — `ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points")`; + значение не `None` и не число ≥ 0 (`bool` — не число) — `failed` «story_points — число ≥ 0 или null» без вызова Jira + после `GET /field`. Сравнение — Global Constraints; отличие — поле в `PUT`, `"story_points"` в `changed`. + +- [ ] **Step 1: Тесты:** + +```python +def test_story_points_set_clear_idempotent(self): + self.fake.add("AURORA-1") + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3) + self.assertEqual(out["result"], {"changed": ["story_points"]}) + self.assertEqual(self.fake.issues["AURORA-1"]["customfield_10106"], 3) + for v in (3, 3.0): + again = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v) + self.assertEqual(again["result"], {"changed": []}) + self.assertEqual(len(self.fake.writes()), 1) + self.run_cmd("ensure_fields", key="AURORA-1", story_points=None) + self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_10106"]) + +def test_story_points_no_field(self): # сценарий «Нет поля оценки» + self.fake.add("AURORA-1") + self.fake.fields = [] + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=3) + self.assertEqual((out["status"], out["error"]), ("failed", "поле оценки не найдено в Jira: задайте field_ids.story_points")) + self.assertEqual(self.fake.writes(), []) + +def test_story_points_override(self): + self.fake.add("AURORA-1") + self.ex.s["field_ids"] = dict(SETTINGS["field_ids"], story_points="customfield_20000") + self.run_cmd("ensure_fields", key="AURORA-1", story_points=5) + self.assertEqual(self.fake.issues["AURORA-1"]["customfield_20000"], 5) + self.assertNotIn(("GET", "/rest/api/2/field"), self.fake.requests) + +def test_story_points_bad_value(self): + self.fake.add("AURORA-1") + for v in ("3", -1, True): + out = self.run_cmd("ensure_fields", key="AURORA-1", story_points=v) + self.assertEqual((out["status"], out["error"]), ("failed", "story_points — число ≥ 0 или null"), v) + self.assertEqual(self.fake.writes(), []) +``` + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_fields -q` → FAIL. +- [ ] **Step 3: Реализация.** +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: оценка story_points в ensure_fields (#49)` (+ Co-Authored-By). + +### Task 5: Команда `ensure_sprint_meta` + +**Files:** +- Modify: `src/agent.py` — модульные `_is_day(value: Any) -> bool`, `_sprint_time(day: str, hour: int) -> str`; + `Executor.sprints_named`, `Executor.sprint_board`, `Executor.do_ensure_sprint_meta` +- Modify: `tests/fake_jira.py` — `self.sprint_seq = 500`, `self.sprint_posts = []` (`(путь, тело)` каждой записи спринта); + `POST /rest/agile/1.0/sprint` — новый спринт `{"id": sprint_seq, "name", "state": "future", "originBoardId", "startDate"?, + "endDate"?, "goal"?}` в `board_sprints[originBoardId]`, если доска там есть, иначе в `self.sprints`; ответ 201 со спринтом; + `POST /rest/agile/1.0/sprint/{id}` — частичная правка (`name`, `startDate`, `endDate`, `goal`, `state`); `state: + "active"` без обеих дат после правки или не из `future`, `state: "closed"` не из `active` — 400; ответ 200 со спринтом +- Modify: `tests/_common.py` — `ZERO_PACING` дополнить `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `fetch_attachment` (0) +- Create: `tests/test_executor_sprints.py` — класс `ExecutorSprintsTest` (setUp как в `test_executor_links.py`: `FakeJira`, + `Executor(..., dict(SETTINGS))`, `self.gaps` — `ex.sleep`; `run_cmd(ctype, **params)`) + +**Interfaces:** +- Consumes: `all_sprints(board)` (Task 3). +- Produces: `_is_day(value) -> bool` — строка `ГГГГ-ММ-ДД` (`re.fullmatch(r"\d{4}-\d{2}-\d{2}")` и `datetime.strptime(value, "%Y-%m-%d")`). +- Produces: `_sprint_time(day, hour) -> str` — `"%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z"))` (R-68). +- Produces: `Executor.sprint_board(self, p: dict) -> Any` — `p.get("board_id")`; нет — `ConfigGap("board_id нет в команде — спринты недоступны")` (R-70). +- Produces: `Executor.sprints_named(self, board, name: str) -> List[dict]` — спринты доски из `all_sprints` с точным именем. +- Produces: `Executor.many_sprints(name: str, found: List[dict]) -> str` (статический) — «несколько спринтов с именем «<имя>»: 5, 6». +- `do_ensure_sprint_meta(p, cmd)`: даты проверяются первыми (`failed` «<поле>: дата не в формате ГГГГ-ММ-ДД: »); + поиск по `sprint`; правила R-69 для `rename_to`; закрытый — «спринт закрыт»; создание — `POST /sprint` `{"name": rename_to or sprint, + "originBoardId": int(board), "startDate"?, "endDate"?, "goal"?}` → `{"sprint_id", "created": True}`; найден — `POST /sprint/{id}` только + с отличающимися `name`/`startDate`/`endDate`/`goal` (`goal` `None` ≡ `""`), нет отличий — без записи → `{"sprint_id", "created": False}`. + +- [ ] **Step 1: Тесты:** + +```python +B = {"board_id": 4216} + +def test_meta_create_then_repeat(self): # сценарий «Создание спринта повтором не дублируется» + self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}] + params = dict(B, sprint="AWPCA_Sprint_4", start_date="2026-10-28", end_date="2026-11-11", goal="свод ВГО") + out = self.run_cmd("ensure_sprint_meta", **params) + self.assertEqual(out["status"], "done", out) + sid = out["result"]["sprint_id"] + self.assertEqual(out["result"], {"sprint_id": sid, "created": True}) + sp = self.fake.board_sprints[4216][-1] + off = datetime.now().astimezone().strftime("%z") + self.assertEqual((sp["id"], sp["name"], sp["state"], sp["originBoardId"], sp["goal"]), (sid, "AWPCA_Sprint_4", "future", 4216, "свод ВГО")) + self.assertEqual((sp["startDate"], sp["endDate"]), ("2026-10-28T09:00:00.000" + off, "2026-11-11T18:00:00.000" + off)) + again = self.run_cmd("ensure_sprint_meta", **params) + self.assertEqual(again["result"], {"sprint_id": sid, "created": False}) + self.assertEqual(len(self.fake.writes()), 1) + +def test_meta_updates_only_diff(self): # даты совпадают по дню, отличается цель + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future", "goal": "старая", + "startDate": "2026-10-28T10:00:00.000+03:00", "endDate": "2026-11-11T19:00:00.000+03:00"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", start_date="2026-10-28", end_date="2026-11-11", goal="новая", **B) + self.assertEqual(out["result"], {"sprint_id": 9, "created": False}) + self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/9", {"goal": "новая"})]) + +def test_meta_rename_and_name_taken(self): ... # S → T: POST {"name": "T"}; есть S и T — «имя занято», без записи + +def test_meta_repeat_after_rename(self): # Review Focus 3, R-69 + self.fake.board_sprints[4216] = [{"id": 9, "name": "T", "state": "future"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual(out["result"], {"sprint_id": 9, "created": False}) + self.assertEqual(self.fake.writes(), []) + +def test_meta_closed_and_duplicates(self): + self.fake.board_sprints[4216] = [{"id": 1, "name": "C", "state": "closed"}, + {"id": 5, "name": "D", "state": "future"}, {"id": 6, "name": "D", "state": "active"}] + self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="C", goal="x", **B)["error"], "спринт закрыт") + self.assertEqual(self.run_cmd("ensure_sprint_meta", sprint="D", **B)["error"], "несколько спринтов с именем «D»: 5, 6") + self.assertEqual(self.fake.writes(), []) + +def test_meta_bad_date(self): # Review Focus 5 + for bad in ("28.10.2026", "2026-13-01"): + out = self.run_cmd("ensure_sprint_meta", sprint="N", start_date=bad, **B) + self.assertEqual(out["status"], "failed") + self.assertIn("ГГГГ-ММ-ДД", out["error"]) + self.assertEqual(self.fake.writes(), []) + +def test_meta_without_board(self): + out = self.run_cmd("ensure_sprint_meta", sprint="N") + self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны")) + self.assertEqual(self.fake.requests, []) +``` + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL («неизвестный тип команды»). +- [ ] **Step 3: Реализация** по Interfaces. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_meta — создание и правка спринта (#49)` (+ Co-Authored-By). + +### Task 6: Команда `ensure_sprint_state` + +**Files:** +- Modify: `src/agent.py` — `Executor.sprint_issues`, `Executor.do_ensure_sprint_state` +- Modify: `tests/fake_jira.py` — `GET /sprint/{id}/issue` без `jql` — все задачи спринта (по `sprint_members` или имени + спринта задачи); каждый элемент `{"key", "fields": {"status": {"name"}}}`; `POST /sprint/{id}/issue` дописывает id в + `sprint_ids` задачи, если тот не `None` +- Test: `tests/test_executor_sprints.py` + +**Interfaces:** +- Consumes: `sprint_board`, `sprints_named`, `many_sprints` (Task 5); `all_sprints`, `board_columns` (Task 3); `self.gap("ensure_sprint_state")`. +- Produces: `Executor.sprint_issues(self, sprint_id) -> List[Tuple[str, Optional[str]]]` — `(ключ, статус)` из + `GET /sprint/{id}/issue?fields=status`, постранично по 50 (`total`). +- `do_ensure_sprint_state(p, cmd)` — порядок проверок: `state` не `active`/`closed` — `failed` «state — active или closed»; + доска; спринт (нет — `failed` «нет спринта «<имя>» на доске », несколько — `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: Тесты:** + +```python +def sprint_with(self, n_done, n_open): # спринт 3 активный, спринт 4 будущий, задачи по имени спринта + self.fake.board_sprints[4216] = [{"id": 3, "name": "AWPCA_Sprint_3_Corrections", "state": "active"}, + {"id": 4, "name": "AWPCA_Sprint_4", "state": "future"}] + open_keys = [] + for i in range(n_done + n_open): + key = "AURORA-%d" % (2000 + i) + self.fake.add(key, sprint="AWPCA_Sprint_3_Corrections", status="Готово" if i < n_done else "Разработка") + if i >= n_done: + open_keys.append(key) + return open_keys + +def test_close_with_move(self): # сценарий «Завершение спринта с переносом» + open_keys = self.sprint_with(25, 12) + params = dict(B, sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="AWPCA_Sprint_4") + out = self.run_cmd("ensure_sprint_state", **params) + self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys}) + self.assertEqual({self.fake.issues[k]["sprint"] for k in open_keys}, {"AWPCA_Sprint_4"}) + self.assertEqual(self.fake.board_sprints[4216][0]["state"], "closed") + writes = len(self.fake.writes()) + again = self.run_cmd("ensure_sprint_state", **params) + self.assertEqual(again["result"], {"sprint_id": 3, "moved": []}) + self.assertEqual(len(self.fake.writes()), writes) + +def test_close_chunks_and_gap(self): + self.sprint_with(0, 60) + self.ex.s["pacing"] = {"ensure_sprint_state": 30} + self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual([p for _, p in self.fake.writes()], + ["/rest/agile/1.0/backlog/issue"] * 2 + ["/rest/agile/1.0/sprint/3"]) + self.assertEqual(self.gaps, [30, 30]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/agile/1.0/sprint/3/issue")), 2) + +def test_activate_future(self): ... # даты есть → POST {"state": "active", startDate, endDate}; повтор — без записи; + # без дат → «у спринта нет дат: задайте ensure_sprint_meta», writes() == [] +def test_state_wrong_transitions(self): ... # closed для future → «спринт не начат»; active для closed → «спринт закрыт»; без записи + +def test_state_bad_move_target_no_writes(self): # Review Focus 4 + self.sprint_with(1, 1) + self.fake.board_sprints[4216].append({"id": 1, "name": "Старый", "state": "closed"}) + for target in ("Нет такого", "Старый", "AWPCA_Sprint_3_Corrections"): + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to=target, **B) + self.assertEqual(out["status"], "failed", target) + self.assertEqual(self.fake.writes(), []) + +def test_close_columns_unreadable(self): + self.sprint_with(1, 1) + self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403] + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски")) + self.assertEqual(self.fake.writes(), []) +``` + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL. +- [ ] **Step 3: Реализация** по Interfaces. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: команда ensure_sprint_state — запуск и завершение спринта с переносом (#49)` (+ Co-Authored-By). + +### Task 7: Команда `ensure_rank` + +**Files:** +- Modify: `src/agent.py` — `Executor.do_ensure_rank` +- Modify: `tests/fake_jira.py` — `PUT /rest/agile/1.0/issue/rank`: тело в `self.rank_bodies`; задачи с ключом в + `self.rank_errors` (ключ → текст) — ответ 207 `{"entries": [{"issueKey", "issueId", "status": 400, "errors": [текст]}]}` + без изменения; иначе ранг `RANK_FIELD` задачи становится строго меньше (`rankBeforeIssue`) или больше + (`rankAfterIssue`) ранга другой задачи (например, `before[:-1] + chr(ord(before[-1]) - 1) + "z"` и `after + "m"`), ответ 204 +- Test: `tests/test_executor_sprints.py` + +**Interfaces:** +- Consumes: `jira_fields()["rank"]` (Task 1). +- `do_ensure_rank(p, cmd)`: ровно одно из `before`/`after` — иначе `failed` «нужно ровно одно из before, after»; другая + задача — та же — `failed` «задача не может стоять выше или ниже самой себя»; поле ранга не найдено — + `failed` «поле ранга не найдено»; ранги — один `GET /search` `jql="key in (,)"`, `fields=`; + `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 не переранжировала <ключ>: » (R-73); иначе `done {"changed": True}`. + +- [ ] **Step 1: Тесты:** + +```python +def test_rank_already(self): # сценарий «Ранг уже такой» + self.fake.add("AURORA-1612", customfield_10105="0|i0001:") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601") + self.assertEqual((out["status"], out["result"]), ("done", {"changed": False})) + self.assertEqual(self.fake.writes(), []) + +def test_rank_before_and_after_write(self): + self.fake.add("AURORA-1612", customfield_10105="0|i0003:") + self.fake.add("AURORA-1601", customfield_10105="0|i0002:") + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601") + self.assertEqual(out["result"], {"changed": True}) + self.assertEqual(self.fake.rank_bodies[-1], {"issues": ["AURORA-1612"], "rankBeforeIssue": "AURORA-1601", "rankCustomFieldId": 10105}) + self.assertLess(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"]) + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1601")["result"], {"changed": False}) + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1612", after="AURORA-1601")["result"], {"changed": True}) + self.assertGreater(self.fake.issues["AURORA-1612"]["customfield_10105"], self.fake.issues["AURORA-1601"]["customfield_10105"]) + +def test_rank_null_rank_writes(self): ... # у key ранга нет (None) → PUT, changed True +def test_rank_bad_params(self): ... # before и after вместе, ни одного, key == before → failed, без записи + +def test_rank_no_field(self): + self.fake.add("AURORA-1"); self.fake.add("AURORA-2") + self.fake.fields = [] + self.assertEqual(self.run_cmd("ensure_rank", key="AURORA-1", after="AURORA-2")["error"], "поле ранга не найдено") + +def test_rank_207_error(self): # Review Focus 1 + self.fake.add("AURORA-1", customfield_10105="0|i0009:"); self.fake.add("AURORA-2", customfield_10105="0|i0001:") + self.fake.rank_errors["AURORA-1"] = "Ранжирование недоступно" + out = self.run_cmd("ensure_rank", key="AURORA-1", before="AURORA-2") + self.assertEqual((out["status"], out["error"]), ("failed", "Jira не переранжировала AURORA-1: Ранжирование недоступно")) +``` + +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_executor_sprints -q` → FAIL. +- [ ] **Step 3: Реализация** по Interfaces. +- [ ] **Step 4: Прогон** — весь набор → PASS. +- [ ] **Step 5: Commit** — `Агент: команда ensure_rank — выше или ниже другой задачи (#49)` (+ Co-Authored-By). + +### Task 8: Версия 15 и документация + +**Files:** +- Modify: `src/agent.py` — `AGENT_VERSION = 15` +- Modify: `README.md` — таблица «Что делает агент с командами»: строки `ensure_sprint_meta`, `ensure_sprint_state`, + `ensure_rank`; в `ensure_fields` — `story_points`; в `refresh` — `rank`, `story_points`, `sprint_ids`, данные досок + (`boards` в первой пачке, пустая пачка); абзац «Версия»: «сейчас 15» и «Версия 15 — …» (поиск полей по `GET /field`, + перекрытие `field_ids.rank`/`sprint`/`story_points`, проверка на Jira банка при выкатке; агент v14 на новые типы отвечает + «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки) +- Modify: `ARCHITECTURE.md` — строка «Исполнитель»: `jira_fields`/`field_list`, `board_state`, `all_sprints`, новые `do_<тип>`; + «Открытые вопросы»: снять «Спринт задач закрытых спринтов: `sprint = null`» (история — `sprint_ids`), оставить про `sprint` +- Modify: `CLAUDE.md` — «Последний change» → `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг, + оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — этот файл; в «Тесты» — + `test_executor_sprints.py` (`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`) +- Modify: `openspec/changes/agent-v15-sprints/design.md` — в таблицу «Decisions made without discussion» строки R-64…R-75 из + раздела «Rulings при планировании» (дата 2026-10-24) и решения исполнителя, если были +- Test: `tests/test_refresh.py` + +- [ ] **Step 1: Тест** — `test_agent_version_15`: `self.assertEqual(agent.AGENT_VERSION, 15)`; после `refresh` с одной задачей + `self.hub.states[0]["agent_version"] == 15`. +- [ ] **Step 2: Прогон** — `/usr/bin/python3 -m unittest tests.test_refresh -q` → FAIL (14 != 15). +- [ ] **Step 3: Реализация** — версия и документы по списку Files. +- [ ] **Step 4: Прогон** — весь набор с `-W error::ResourceWarning` → PASS; `/usr/bin/python3 -m py_compile src/agent.py` без вывода. +- [ ] **Step 5: Commit** — `Агент v15: версия и документация спринтов, ранга и оценок (#49)` (+ Co-Authored-By). -- 2.47.2 From 9fbe885ba8127b86699d73b9def954a45c41e70c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:23:15 +0400 Subject: [PATCH 03/19] =?UTF-8?q?OpenSpec:=20R-76=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 31b4c1c..ec8a5bb 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -59,3 +59,4 @@ | R-61 | 2026-10-24 | `sprint_ids` из поля спринта в `search`, разбор строкового вида Jira Server (п. 2) | Обход закрытых спринтов — N запросов на спринт; поле есть у каждой задачи | Формат поля банка иной — `sprint_ids` пропадут, видно на живой проверке | | R-62 | 2026-10-24 | Kanban-бэклог — по ответу `GET /board/{id}/backlog` (п. 4) | В `configuration` Jira Server явного признака нет | `null` — у kanban нет раздела «Бэклог» | | R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками | +| R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов | -- 2.47.2 From 21374bef081239fb081ffe4769364901fd9ffd31 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:30:55 +0400 Subject: [PATCH 04/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B3=D0=B0,=20=D1=81=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20=D0=B8=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=81=20=D0=BA=D1=8D=D1=88=D0=B5=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20field=5Fids=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 56 ++++++++++++++++++++++++++++++++++++++++--- tests/fake_jira.py | 11 ++++++++- tests/test_refresh.py | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/agent.py b/src/agent.py index 3007ff5..7c04c37 100644 --- a/src/agent.py +++ b/src/agent.py @@ -669,16 +669,26 @@ def _fmt_size(n: Optional[int]) -> str: return ("%.1f МБ" % (n / 1024 / 1024)).replace(".", ",") +RANK_SCHEMA = "com.pyxis.greenhopper.jira:gh-lexo-rank" # схема поля «Rank» (ранг Lexorank) +SPRINT_SCHEMA = "com.pyxis.greenhopper.jira:gh-sprint" # схема поля «Sprint» + + class Executor: def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None): self.jira, self.s, self.hub = jira, settings, hub self.sleep = time.sleep self._symmetric = None self._sprints = {} # str(доска) → имя → спринт; на одну команду + self._field_list = None # GET /field: список полей на одну команду + self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields + self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor def run(self, cmd: dict) -> dict: self.jira.reset_counters() self._sprints = {} + self._field_list = None + if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67) + self._jira_fields = None handler = getattr(self, "do_" + str(cmd.get("type")), None) if handler is None: return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) @@ -724,6 +734,47 @@ class Executor: def has_field(self, logical: str) -> bool: return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical)) + def field_list(self) -> List[dict]: + """GET /rest/api/2/field — один раз на команду; только элементы-словари. Ошибки Jira и связи — наружу.""" + if self._field_list is None: + self._field_list = [f for f in self.jira.get(API + "/field") or [] if isinstance(f, dict)] + return self._field_list + + def jira_fields(self) -> Dict[str, Optional[str]]: + """Поля ранга, спринта и оценки: {"rank", "sprint", "story_points"} → id или None. Настроенный в field_ids id + берётся как есть; остальные ищутся в field_list: ранг и спринт по схеме, оценка по имени «story points» (регистр + не важен), при нескольких — наименьший (len(id), id). Ошибка HTTP — лог, None, поиск повторится в следующей + команде; связь — наружу.""" + if self._jira_fields is not None: + return self._jira_fields + ids = self.s.get("field_ids") or {} + over = {k: ids.get(k) or None for k in ("rank", "sprint", "story_points")} + if all(over.values()): + self._jira_fields, self._jira_fields_ok = dict(over), True + return self._jira_fields + try: + fields = self.field_list() + except HttpError as e: + log("поля Jira не прочитаны (HTTP %d) — ранг, спринт и оценка неизвестны" % e.code) + self._jira_fields, self._jira_fields_ok = over, False + return self._jira_fields + + def find(match) -> Optional[str]: + cands = sorted((f for f in fields if match(f)), key=lambda f: (len(str(f.get("id"))), str(f.get("id")))) + return cands[0].get("id") if cands else None + + def by_schema(schema: str): + return lambda f: isinstance(f.get("schema"), dict) and f["schema"].get("custom") == schema + + def by_name(name: str): + return lambda f: str(f.get("name") or "").strip().casefold() == name + + found = {"rank": find(by_schema(RANK_SCHEMA)), "sprint": find(by_schema(SPRINT_SCHEMA)), + "story_points": find(by_name("story points"))} + self._jira_fields = {k: over[k] or found[k] for k in found} + self._jira_fields_ok = True + return self._jira_fields + def priority_value(self, name: str) -> dict: mapped = (self.s.get("priority_map") or {}).get(name, name) return {self.s.get("priority_value_key") or "name": mapped} @@ -1392,9 +1443,8 @@ class Executor: names: Dict[str, Any] = {} def field_id_of(name: str) -> Optional[str]: - if "map" not in names: # один GET /field за refresh, только по нужде - names["map"] = {f.get("name"): f.get("id") for f in self.jira.get(API + "/field") or [] - if isinstance(f, dict)} + if "map" not in names: # по списку полей команды — GET /field один раз (field_list) + names["map"] = {f.get("name"): f.get("id") for f in self.field_list()} return names["map"].get(name) items: List[dict] = [] diff --git a/tests/fake_jira.py b/tests/fake_jira.py index bbbe930..9a176e6 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -21,6 +21,9 @@ WORKFLOW = { # статус → [(имя перехода, целевой ст } LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начало заблокировано", "outward": "Блокирует начало"}, {"name": "ASUPR_Связанные", "inward": "Связанные", "outward": "Связанные"}] +SPRINT_FIELD = "customfield_10104" +RANK_FIELD = "customfield_10105" +POINTS_FIELD = "customfield_10106" CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") @@ -100,7 +103,13 @@ class FakeJira: self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap) self.jql_results = {} # JQL → ключи (проверяется в search_keys первым) self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True}, - {"id": "customfield_10104", "name": "Sprint", "custom": True}, + {"id": SPRINT_FIELD, "name": "Sprint", "custom": True, + "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"}}, {"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field self.lock = threading.Lock() fake = self diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 3c336f6..ba0aecd 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -469,3 +469,57 @@ class RefreshTest(unittest.TestCase): if __name__ == "__main__": unittest.main() + + +class JiraFieldsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.hub = FakeHub(SETTINGS) + cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token) + self.jira = agent.JiraClient(cfg) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg)) + self.ex.sleep = no_sleep + + def tearDown(self): + self.fake.stop() + self.hub.stop() + + def logged(self, fn): + logs, orig = [], agent.log + agent.log = logs.append + try: + return fn(), logs + finally: + agent.log = orig + + 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") -- 2.47.2 From 79f18abbfab0e4b960c9d04e383d6e4c433d949d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:32:10 +0400 Subject: [PATCH 05/19] =?UTF-8?q?OpenSpec:=20R-77=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index ec8a5bb..3a8bd98 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -60,3 +60,4 @@ | R-62 | 2026-10-24 | Kanban-бэклог — по ответу `GET /board/{id}/backlog` (п. 4) | В `configuration` Jira Server явного признака нет | `null` — у kanban нет раздела «Бэклог» | | R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками | | R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов | +| R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` | -- 2.47.2 From 9de78842b1fa2d73bddf4c5ac3429e1b0c4ec2ea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:37:03 +0400 Subject: [PATCH 06/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B3,=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=20=D0=B2=D1=81=D0=B5=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8=20refres?= =?UTF-8?q?h=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 44 ++++++++++++++++++++++++++++++++++++-- tests/fake_jira.py | 22 +++++++++++++++++-- tests/test_refresh.py | 49 +++++++++++++++++++++++++++++++++++++++---- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/agent.py b/src/agent.py index 7c04c37..b9aef90 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1118,7 +1118,8 @@ class Executor: ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан) if ep.get("mode", "field") == "field" and ep.get("field_id"): extra.append(ep["field_id"]) - return ",".join(self.STATE_FIELDS + extra) + extra += [v for v in self.jira_fields().values() if v] + return ",".join(dict.fromkeys(self.STATE_FIELDS + extra)) def search_all(self, jql: str) -> List[dict]: issues, start = [], 0 @@ -1157,15 +1158,49 @@ class Executor: def name(obj): return (obj or {}).get("name") + jf = self.jira_fields() ep = self.s.get("epic_link") or {} epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None - return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), + st = {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"), "description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")), "assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")), "planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None, "planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None, "epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None, "created": f.get("created"), "jira_updated": f.get("updated"), "attachments": self.attachments_of(raw)} + if jf["rank"]: # поле не найдено — ключа нет, а не null (R-64) + v = f.get(jf["rank"]) + st["rank"] = v if isinstance(v, str) else None + if jf["story_points"]: + v = f.get(jf["story_points"]) + st["story_points"] = v if isinstance(v, (int, float)) and not isinstance(v, bool) else None + if jf["sprint"]: + st["sprint_ids"] = self.sprint_ids_of(raw["key"], f.get(jf["sprint"])) + return st + + @staticmethod + def sprint_ids_of(key: str, value: Any) -> List[int]: + """id спринтов из поля спринтов: элементы — словари (id — число или строка цифр) или строки greenhopper + («...[id=72,...]»). Не разобранный элемент — лог и пропуск; повторы убираются.""" + out: List[int] = [] + if not isinstance(value, list): + return out + for it in value: + sid = None + if isinstance(it, dict): + i = it.get("id") + if isinstance(i, int) and not isinstance(i, bool): + sid = i + elif isinstance(i, str) and i.isdigit(): + sid = int(i) + elif isinstance(it, str): + m = re.search(r"[\[,]id=(\d+)", it) + sid = int(m.group(1)) if m else None + if sid is None: + log(" ! %s: элемент поля спринта не разобран" % key) + elif sid not in out: + out.append(sid) + return out def links_from(self, raw: dict) -> List[dict]: key, out = raw["key"], [] @@ -1367,6 +1402,11 @@ class Executor: for i in range(0, len(absent), 100): neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors + if everything: + jf = self.jira_fields() + for k, what, name in (("rank", "ранга", "rank"), ("sprint", "спринта", "sprint_ids"), ("story_points", "оценки", "story_points")): + if not jf[k]: + log("refresh: поле %s не найдено в Jira — %s у задач не передаётся" % (what, name)) types = dict(types or {}) names = self.sprint_names([r["key"] for r in everything], scrum, types) issues, links, seen = [], [], set() diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 9a176e6..25e172c 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -24,7 +24,7 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал SPRINT_FIELD = "customfield_10104" RANK_FIELD = "customfield_10105" POINTS_FIELD = "customfield_10106" -CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304") +CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD) def parse_multipart(raw, content_type): @@ -74,6 +74,7 @@ class FakeJira: self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id} self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints + self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 @@ -149,7 +150,7 @@ class FakeJira: base = {"summary": "Тема " + key, "description": None, "status": "Ожидание разработки", "priority": "Средний", "assignee": None, "reporter": "someone", "duedate": None, "labels": ["AWPCA"] if in_base else [], "issuetype": "Задача", "created": self.ts(self.now - timedelta(hours=1)), "updated": "2026-10-01T10:00:00.000+0300", - "sprint": None} + "sprint": None, "sprint_ids": None} self.id_seq += 1 base["id"] = str(self.id_seq) base.update({c: None for c in CUSTOM}) @@ -211,6 +212,7 @@ class FakeJira: "duedate": f["duedate"], "labels": list(f["labels"]), "issuetype": {"name": f["issuetype"]}, "created": f["created"], "updated": f["updated"], "issuelinks": self.links_of(key)} out.update({c: f.get(c) for c in CUSTOM}) + out[SPRINT_FIELD] = self.sprint_value(f) if fields is None or "comment" in fields: lst = self.comments.get(key, []) cap = self.search_comment_cap if fields is not None else None @@ -221,6 +223,22 @@ class FakeJira: out["attachment"] = [self.attachment_json(a) for a in self.attachments.get(key, [])] return {"key": key, "id": f["id"], "fields": out} + def sprint_value(self, f): + """Значение поля спринтов задачи: sprint_raw как есть; иначе спринты по sprint_ids, иначе по имени sprint.""" + if "sprint_raw" in f: + return f["sprint_raw"] + allsp = self.all_sprints() + if f.get("sprint_ids") is not None: + sps = [s for i in f["sprint_ids"] for s in allsp if s["id"] == i] + else: + sps = [s for s in allsp if s["name"] == f.get("sprint")][:1] if f.get("sprint") else [] + if not sps: + return None + if self.sprint_field_format == "string": + return ["com.atlassian.greenhopper.service.sprint.Sprint@1f2e3d[id=%d,rapidViewId=4216,state=%s,name=%s," + "sequence=%d]" % (s["id"], s["state"].upper(), s["name"], s["id"]) for s in sps] + return [{"id": s["id"], "name": s["name"], "state": s["state"]} for s in sps] + def links_of(self, key): out = [] for l in self.links: diff --git a/tests/test_refresh.py b/tests/test_refresh.py index ba0aecd..bd318eb 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -336,6 +336,7 @@ class RefreshTest(unittest.TestCase): self.assertFalse(out["retryable"]) def test_jira_error_has_no_peer(self): + self.ex.jira_fields() self.fake.fail_next.append((500, {})) out = self.ex.run({"id": 1, "type": "refresh", "params": {"jql": "project = AURORA"}}) self.assertTrue(out["retryable"]) @@ -354,6 +355,46 @@ class RefreshTest(unittest.TestCase): finally: agent.log = orig + 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) + def test_two_boards_one_new(self): # сценарий «Две доски, одна новая» self.fake.boards[5120] = "kanban" self.fake.add("AURORA-1", sprint="Спринт 2") @@ -467,10 +508,6 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) -if __name__ == "__main__": - unittest.main() - - class JiraFieldsTest(unittest.TestCase): def setUp(self): self.fake = FakeJira() @@ -523,3 +560,7 @@ class JiraFieldsTest(unittest.TestCase): 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") + + +if __name__ == "__main__": + unittest.main() -- 2.47.2 From 0ea1438b1e024f6f8fa5c93500dc64a7a2570db8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:47:22 +0400 Subject: [PATCH 07/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20kanban-=D0=B1=D1=8D?= =?UTF-8?q?=D0=BA=D0=BB=D0=BE=D0=B3=20=D0=B8=20=D0=B2=D1=81=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BE=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B2=20=D0=BF=D0=B5=D1=80=D0=B2=D0=BE=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=87=D0=BA=D0=B5=20refresh=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 124 ++++++++++++++++++++++++++++++++++++++---- tests/fake_jira.py | 29 +++++++++- tests/test_refresh.py | 95 +++++++++++++++++++++++++++++++- 3 files changed, 234 insertions(+), 14 deletions(-) diff --git a/src/agent.py b/src/agent.py index b9aef90..0c2ab5e 100644 --- a/src/agent.py +++ b/src/agent.py @@ -679,6 +679,8 @@ class Executor: self.sleep = time.sleep self._symmetric = None self._sprints = {} # str(доска) → имя → спринт; на одну команду + self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду + self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor self._field_list = None # GET /field: список полей на одну команду self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor @@ -686,6 +688,7 @@ class Executor: def run(self, cmd: dict) -> dict: self.jira.reset_counters() self._sprints = {} + self._all_sprints = {} self._field_list = None if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67) self._jira_fields = None @@ -1191,7 +1194,7 @@ class Executor: i = it.get("id") if isinstance(i, int) and not isinstance(i, bool): sid = i - elif isinstance(i, str) and i.isdigit(): + elif isinstance(i, str) and i.isascii() and i.isdigit(): sid = int(i) elif isinstance(it, str): m = re.search(r"[\[,]id=(\d+)", it) @@ -1313,13 +1316,14 @@ class Executor: log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)) return None - def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]: - """(scrum-доски для имён спринтов, типы запрошенных досок для результата или None). - Без boards в команде — хаб до проектов: доска из настроек, как v11.""" + def refresh_boards(self, p: dict) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]: + """(scrum-доски для имён спринтов, доски с типом scrum/kanban для данных досок, типы запрошенных досок для + результата или None). Без boards в команде — хаб до проектов: доска из настроек, как v11.""" if "boards" not in p: board = self.s.get("board_id") - return ([board] if board else []), None + return ([board] if board else []), [], None scrum: List[Any] = [] + typed: List[Tuple[Any, str]] = [] types: Dict[str, Optional[str]] = {} seen = set() for b in p.get("boards") or []: @@ -1334,7 +1338,89 @@ class Executor: t = types[str(bid)] = self.board_type(bid) if t == "scrum": scrum.append(bid) - return scrum, (types or None) + if t in ("scrum", "kanban"): + typed.append((bid, t)) + return scrum, typed, (types or None) + + # --- данные досок в первой пачке refresh (v15) ------------------------------------------------------------- + def status_names(self) -> Dict[str, str]: + """id статуса → имя (GET /status); кэш на экземпляр, ошибка Jira наружу и не кэшируется.""" + if self._status_names is None: + self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")} + return self._status_names + + def board_columns(self, board: Any) -> Optional[List[dict]]: + """Колонки доски: [{name, statuses: [имя]}]; неизвестный id статуса пропускается; ошибка Jira — лог и None.""" + try: + names = self.status_names() + cfg = self.jira.get(AGILE + "/board/%s/configuration" % board) + except HttpError as e: + log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code)) + return None + out = [] + for col in (cfg.get("columnConfig") or {}).get("columns") or []: + out.append({"name": col.get("name"), + "statuses": [names[str(st.get("id"))] for st in col.get("statuses") or [] if str(st.get("id")) in names]}) + return out + + def kanban_backlog(self, board: Any) -> Optional[bool]: + """Включён ли бэклог канбан-доски: ответ — True, 400 — False, иная ошибка — None с записью в лог.""" + try: + self.jira.get(AGILE + "/board/%s/backlog" % board, maxResults=0) + except HttpError as e: + if e.code == 400: + return False + log("refresh: доска %s — бэклог не прочитан (HTTP %d)" % (board, e.code)) + return None + return True + + def all_sprints(self, board: Any) -> List[dict]: + """Все спринты доски любых состояний (GET /board/{id}/sprint без state, по 50); кэш на команду. + Заодно заполняет self._sprints именами активных и будущих, чтобы sprints() не спрашивал Jira повторно.""" + key = str(board) + if key not in self._all_sprints: + out, start = [], 0 + while True: + page = self.jira.get(AGILE + "/board/%s/sprint" % board, startAt=start, maxResults=50) + vals = page.get("values") or [] + out += vals + if page.get("isLast", True) or not vals: + break + start += len(vals) + self._all_sprints[key] = out + self._sprints[key] = {sp["name"]: sp for sp in out + if sp.get("name") and str(sp.get("state")).lower() in ("active", "future")} + return self._all_sprints[key] + + @staticmethod + def sprint_state(sp: dict) -> Optional[dict]: + """Спринт для хаба; без id/name или с состоянием вне active/future/closed — None (R-72).""" + state = sp.get("state") + state = state.lower() if isinstance(state, str) else None + if sp.get("id") is None or not sp.get("name") or state not in ("active", "future", "closed"): + return None + return {"id": sp["id"], "name": sp["name"], "state": state, "start_date": sp.get("startDate"), + "end_date": sp.get("endDate"), "complete_date": sp.get("completeDate"), "goal": sp.get("goal")} + + def board_state(self, board: Any, kind: str) -> Optional[dict]: + """Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null.""" + columns = self.board_columns(board) + backlog = self.kanban_backlog(board) if kind == "kanban" else None + sprints: List[dict] = [] + if kind == "scrum": + try: + raw = self.all_sprints(board) + except HttpError as e: + log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" % (board, e.code)) + return None + for sp in raw: + st = self.sprint_state(sp) + if st is None: + log("refresh: доска %s — спринт %r пропущен: нет id/name или состояние не active/future/closed" + % (board, sp.get("id"))) + else: + sprints.append(st) + return {"id": board, "type": kind, "columns": columns, "kanban_backlog": backlog, "sprints": sprints} def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]: """Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается @@ -1372,16 +1458,26 @@ class Executor: break return names + def send_empty_batch(self, cmd: dict, boards: List[dict]) -> None: + self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": [], "links": [], + "neighbor_keys": [], "page": 1, "last_page": True, "boards": boards}) + def do_refresh(self, p: dict, cmd: dict) -> dict: missing: List[str] = [] errors: List[HttpError] = [] - scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys + scrum, typed, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys + boards = None + if "boards" in p: + boards = [b for b in (self.board_state(bid, kind) for bid, kind in typed) if b is not None] if p.get("jql"): raws = self.search_all(p["jql"]) else: keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k)) if not keys: empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0} + if boards is not None: # данные досок уходят пустой пачкой (R-66) + self.send_empty_batch(cmd, boards) + empty["pages"] = 1 if types is not None: empty["boards"] = types return self._out("done", empty) @@ -1428,10 +1524,16 @@ class Executor: chunk = issues[i:i + 100] chunk_keys = {x["key"] for x in chunk} pages += 1 - self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk, - "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], - "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], - "page": pages, "last_page": i + 100 >= len(issues)}) + state = {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk, + "links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys], + "neighbor_keys": [k for k in neighbor_keys if k in chunk_keys], + "page": pages, "last_page": i + 100 >= len(issues)} + if pages == 1 and boards is not None: + state["boards"] = boards + self._hub(self.hub.state, state) + if not pages and boards is not None: # выборка пуста, а доски прочитаны (R-66) + self.send_empty_batch(cmd, boards) + pages = 1 result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} history = self.refresh_history(p, cmd, everything) if history is not None: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 25e172c..a43c429 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -24,6 +24,9 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал SPRINT_FIELD = "customfield_10104" RANK_FIELD = "customfield_10105" POINTS_FIELD = "customfield_10106" +STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ", "10003": "Разработка", + "10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"} +COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])] CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD) @@ -74,6 +77,9 @@ class FakeJira: self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}] self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id} self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints + self.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS) + self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200) + self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 @@ -286,6 +292,9 @@ class FakeJira: if self.fail_next: code, headers = self.fail_next.pop(0) return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers) + if self.fail_paths.get(url.path): + code = self.fail_paths[url.path].pop(0) + return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, {}) try: code, payload = self.route(method, url.path, q, body) except KeyError as e: @@ -472,6 +481,22 @@ class FakeJira: if board not in self.boards: return 404, {"errorMessages": ["Board does not exist"]} return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]} + if p == "/rest/api/2/status": + return 200, [{"id": i, "name": n} for i, n in STATUSES.items()] + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p) + if m: + board = int(m.group(1)) + if board not in self.boards: + return 404, {"errorMessages": ["Board does not exist"]} + cols = self.board_columns.get(board, COLUMNS) + return 200, {"id": board, "columnConfig": {"columns": [ + {"name": n, "statuses": [{"id": i} for i in ids]} for n, ids in cols]}} + m = re.match(r"^/rest/agile/1\.0/board/(\d+)/backlog$", p) + if m: + code = self.backlog_codes.get(int(m.group(1)), 200) + if code != 200: + return code, {"errorMessages": ["имитация ошибки"]} + return 200, {"startAt": 0, "maxResults": 0, "total": 0, "issues": []} m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) if m: board = int(m.group(1)) @@ -479,8 +504,8 @@ class FakeJira: return 404, {"errorMessages": ["Board does not exist"]} if self.boards.get(board) == "kanban": return 400, {"errorMessages": ["The board does not support sprints"]} - states = (q.get("state") or "active,future").split(",") - vals = [s for s in self.board_sprints.get(board, self.sprints) if s["state"] in states] + states = q["state"].split(",") if q.get("state") else None # без state — спринты всех состояний + vals = [s for s in self.board_sprints.get(board, self.sprints) if states is None or s["state"] in states] start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)} m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index bd318eb..7783f35 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -5,6 +5,11 @@ from fake_hub import FakeHub from fake_jira import FakeJira +COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]}, + {"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]}, + {"name": "Готово", "statuses": ["Готово", "Снято"]}] + + class RefreshTest(unittest.TestCase): def setUp(self): self.fake = FakeJira() @@ -430,6 +435,8 @@ class RefreshTest(unittest.TestCase): self.assertNotIn("boards", out["result"]) self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests]) self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + self.assertNotIn("boards", self.hub.states[0]) + self.assertFalse(any(r[1].endswith("/configuration") for r in self.fake.requests)) def test_known_boards_no_type_requests(self): self.fake.add("AURORA-1", sprint="Спринт 2") @@ -471,7 +478,11 @@ class RefreshTest(unittest.TestCase): self.fake.boards[5120] = "kanban" out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}]) self.assertEqual(out["status"], "done") - self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}}) + self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 1, "boards": {"5120": "kanban"}}) + self.assertEqual(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": []}]}]) def test_last_found_sprint_wins_across_boards(self): self.fake.boards[5120] = "scrum" @@ -507,6 +518,88 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done", out) self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + 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): + self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": "closed"} for i in range(1, 121)] + self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]) + 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(reads), 3) + self.assertEqual(len(self.hub.states[0]["boards"][0]["sprints"]), 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): # R-72 + 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): # R-66 + 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) + + def test_sprint_ids_non_ascii_digits(self): + self.assertEqual(self.ex.sprint_ids_of("AURORA-1", [{"id": "²"}, {"id": "٣"}, {"id": "7"}]), [7]) + class JiraFieldsTest(unittest.TestCase): def setUp(self): -- 2.47.2 From 514e98e8197fab7d3da959ba60c9c48f31687ae0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:49:35 +0400 Subject: [PATCH 08/19] =?UTF-8?q?OpenSpec:=20R-78=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 3a8bd98..5b637f3 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -61,3 +61,4 @@ | R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками | | R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов | | R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` | +| R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет | -- 2.47.2 From 255784c858a38b8ea05ea1900b430d699120f518 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:57:12 +0400 Subject: [PATCH 09/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20story=5Fpoints=20=D0=B2=20ensu?= =?UTF-8?q?re=5Ffields=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 21 +++++++++++++++++++++ tests/test_executor_fields.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/agent.py b/src/agent.py index 0c2ab5e..b9a4089 100644 --- a/src/agent.py +++ b/src/agent.py @@ -641,6 +641,15 @@ def _nl(value) -> str: return (value or "").replace("\r\n", "\n") +def _same_points(a, b) -> bool: + """Равны ли оценки story_points: None равно только None; числа — с допуском 1e-9 (3 == 3.0); bool — не число.""" + if a is None or b is None: + return a is None and b is None + if isinstance(a, bool) or isinstance(b, bool) or not isinstance(a, (int, float)) or not isinstance(b, (int, float)): + return False + return abs(a - b) < 1e-9 + + class ConfigGap(Exception): """В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле.""" @@ -846,6 +855,15 @@ class Executor: # --- ensure_fields --------------------------------------------------------- def do_ensure_fields(self, p: dict, cmd: dict) -> dict: key = p["key"] + sp_fid = None + if "story_points" in p: + # Настроенный id берётся без GET /field (как в refresh он не нужен); иначе — поиск в jira_fields. + sp_fid = (self.s.get("field_ids") or {}).get("story_points") or self.jira_fields()["story_points"] + if not sp_fid: + raise ConfigGap("поле оценки не найдено в Jira: задайте field_ids.story_points") + want_sp = p["story_points"] + if want_sp is not None and (isinstance(want_sp, bool) or not isinstance(want_sp, (int, float)) or not want_sp >= 0): + return self._out("failed", error="story_points — число ≥ 0 или null") cur = self.jira.get(API + "/issue/%s" % key)["fields"] fields, changed = {}, [] if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""): @@ -884,6 +902,9 @@ class Executor: if new != labels: fields[self.fid("labels")] = new changed.append("labels") + if sp_fid and not _same_points(cur.get(sp_fid), p["story_points"]): + fields[sp_fid] = p["story_points"] + changed.append("story_points") if not fields: return self._out("done", {"changed": []}) self.jira.put(API + "/issue/%s" % key, {"fields": fields}) diff --git a/tests/test_executor_fields.py b/tests/test_executor_fields.py index a904a3f..7b4846a 100644 --- a/tests/test_executor_fields.py +++ b/tests/test_executor_fields.py @@ -259,6 +259,40 @@ class ExecutorFieldsTest(unittest.TestCase): out = self.run_cmd("ensure_fields", key="AURORA-1", priority="3") self.assertEqual(out["result"], {"changed": []}) + # --- story_points (Task 4, #49) ---------------------------------------------- + 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(), []) + if __name__ == "__main__": unittest.main() -- 2.47.2 From 70825ff4d1a098d5b11bfc3d967e3eb4d66ff04b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 18:58:14 +0400 Subject: [PATCH 10/19] =?UTF-8?q?OpenSpec:=20R-79=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 5b637f3..46e7a56 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -62,3 +62,4 @@ | R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов | | R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` | | R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет | +| R-79 | 2026-10-24 | `ensure_fields`: настроенный `field_ids.story_points` читается напрямую, без `jira_fields()`; значение `story_points` проверяется до `GET /issue` | Результат совпадает с `jira_fields`; при частичном перекрытии не делается лишний `GET /field` (тест `test_story_points_override`) | Нет | -- 2.47.2 From 54f5781b9800775f7119366ec4457a7a7fdaabf6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:04:10 +0400 Subject: [PATCH 11/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20ensure=5Fsprint=5Fmeta?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D0=B0=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Команда ищет спринт доски по имени, создаёт его или записывает только отличающиеся имя, даты (по дню), цель. Повтор после переименования находит спринт по новому имени; оба имени заняты — ошибка без записи. Даты проверяются первыми, доска берётся только из команды. Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 80 ++++++++++++++++++++++++++++++ tests/_common.py | 3 +- tests/fake_jira.py | 23 +++++++++ tests/test_executor_sprints.py | 91 ++++++++++++++++++++++++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/test_executor_sprints.py diff --git a/src/agent.py b/src/agent.py index b9a4089..a825aae 100644 --- a/src/agent.py +++ b/src/agent.py @@ -650,6 +650,22 @@ def _same_points(a, b) -> bool: return abs(a - b) < 1e-9 +def _is_day(value: Any) -> bool: + """Строка ГГГГ-ММ-ДД, которую разбирает strptime (28.10.2026 и 2026-13-01 — нет).""" + if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + return False + try: + datetime.strptime(value, "%Y-%m-%d") + except ValueError: + return False + return True + + +def _sprint_time(day: str, hour: int) -> str: + """День спринта → время Jira (`ГГГГ-ММ-ДДTЧЧ:00:00.000+ЗЗЗЗ`) со смещением локального часового пояса (R-68).""" + return "%sT%02d:00:00.000%s" % (day, hour, datetime.now().astimezone().strftime("%z")) + + class ConfigGap(Exception): """В настройках хаба нет того, что нужно команде (R-11): команда падает с понятным текстом, а не молча теряет поле.""" @@ -1063,6 +1079,70 @@ class Executor: self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) + # --- ensure_sprint_meta ---------------------------------------------------- + def sprint_board(self, p: dict) -> Any: + """Доска из команды; без неё спринты недоступны (R-70): доска из настроек хаба не подменяет команду.""" + board = p.get("board_id") + if not board: + raise ConfigGap("board_id нет в команде — спринты недоступны") + return board + + def sprints_named(self, board: Any, name: str) -> List[dict]: + """Все спринты доски board (любых состояний) с точным именем name.""" + return [sp for sp in self.all_sprints(board) if sp.get("name") == name] + + @staticmethod + def many_sprints(name: str, found: List[dict]) -> str: + return "несколько спринтов с именем «%s»: %s" % (name, ", ".join(str(sp["id"]) for sp in found)) + + def do_ensure_sprint_meta(self, p: dict, cmd: dict) -> dict: + """Имя, даты и цель спринта доски. Даты проверяются первыми; спринт ищется по `sprint`, а при `rename_to` — + по нему же (R-69): повтор после переименования находит спринт по новому имени; оба имени заняты — ошибка.""" + for field in ("start_date", "end_date"): + if p.get(field) is not None and not _is_day(p[field]): + return self._out("failed", error="%s: дата не в формате ГГГГ-ММ-ДД: %r" % (field, p[field])) + board = self.sprint_board(p) + name, rename_to = p["sprint"], p.get("rename_to") or None + if rename_to == name: + rename_to = None + found = self.sprints_named(board, name) + if len(found) > 1: + return self._out("failed", error=self.many_sprints(name, found)) + to = [] + if rename_to: + to = [sp for sp in self.sprints_named(board, rename_to) if str(sp.get("state")).lower() != "closed"] + if len(to) > 1: + return self._out("failed", error=self.many_sprints(rename_to, to)) + if found and to: + return self._out("failed", error="имя «%s» занято: есть и «%s», и «%s»" % (rename_to, name, rename_to)) + cur = found[0] if found else (to[0] if to else None) + if cur is not None and str(cur.get("state")).lower() == "closed": + return self._out("failed", error="спринт закрыт") + target = rename_to or name + if cur is None: + body = {"name": target, "originBoardId": int(board)} + if p.get("start_date") is not None: + body["startDate"] = _sprint_time(p["start_date"], 9) + if p.get("end_date") is not None: + body["endDate"] = _sprint_time(p["end_date"], 18) + if p.get("goal") is not None: + body["goal"] = p["goal"] + created = self.jira.post(AGILE + "/sprint", body) + return self._out("done", {"sprint_id": created["id"], "created": True}) + diff = {} + if cur.get("name") != target: + diff["name"] = target + if p.get("start_date") is not None and _date(cur.get("startDate")) != p["start_date"]: + diff["startDate"] = _sprint_time(p["start_date"], 9) + if p.get("end_date") is not None and _date(cur.get("endDate")) != p["end_date"]: + diff["endDate"] = _sprint_time(p["end_date"], 18) + if p.get("goal") is not None and (cur.get("goal") or "") != p["goal"]: + diff["goal"] = p["goal"] + if not diff: + return self._out("done", {"sprint_id": cur["id"], "created": False}) + self.jira.post(AGILE + "/sprint/%s" % cur["id"], diff) + return self._out("done", {"sprint_id": cur["id"], "created": False}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/_common.py b/tests/_common.py index b581957..79a21f7 100644 --- a/tests/_common.py +++ b/tests/_common.py @@ -9,7 +9,8 @@ if SRC not in sys.path: import agent # noqa: E402 ZERO_PACING = {"ensure_issue": 0, "ensure_fields": 0, "ensure_comment": 0, "ensure_status": 0, "ensure_link": 0, - "ensure_sprint": 0, "ensure_attachment": 0, "refresh": 0} + "ensure_sprint": 0, "ensure_sprint_meta": 0, "ensure_sprint_state": 0, "ensure_rank": 0, + "ensure_attachment": 0, "fetch_attachment": 0, "refresh": 0} SETTINGS = { "base_jql": "project = AURORA AND labels = AWPCA", diff --git a/tests/fake_jira.py b/tests/fake_jira.py index a43c429..fa7c9a8 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -82,6 +82,8 @@ class FakeJira: self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) + self.sprint_seq = 500 # id следующего созданного спринта + self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой @@ -508,6 +510,27 @@ class FakeJira: vals = [s for s in self.board_sprints.get(board, self.sprints) if states is None or s["state"] in states] start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)} + if p == "/rest/agile/1.0/sprint" and method == "POST": + self.sprint_posts.append((p, body)) + self.sprint_seq += 1 + sp = {"id": self.sprint_seq, "name": body["name"], "state": "future", "originBoardId": body["originBoardId"]} + sp.update({k: body[k] for k in ("startDate", "endDate", "goal") if k in body}) + target = self.board_sprints[body["originBoardId"]] if body["originBoardId"] in self.board_sprints else self.sprints + target.append(sp) + return 201, sp + m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)$", p) + if m and method == "POST": + self.sprint_posts.append((p, body)) + sp = next((s for s in self.all_sprints() if s["id"] == int(m.group(1))), None) + if sp is None: + return 404, {"errorMessages": ["Sprint does not exist"]} + if "state" in body: + st = body["state"] + dated = bool(body.get("startDate", sp.get("startDate")) and body.get("endDate", sp.get("endDate"))) + if (st == "active" and (sp["state"] != "future" or not dated)) or (st == "closed" and sp["state"] != "active"): + return 400, {"errorMessages": ["недопустимый переход состояния спринта"]} + sp.update({k: body[k] for k in ("name", "startDate", "endDate", "goal", "state") if k in body}) + return 200, sp m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p) if m: sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1))) diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py new file mode 100644 index 0000000..d21fba7 --- /dev/null +++ b/tests/test_executor_sprints.py @@ -0,0 +1,91 @@ +import unittest +from datetime import datetime + +from _common import SETTINGS, agent, jira_cfg, no_sleep +from fake_jira import FakeJira + +B = {"board_id": 4216} + + +class ExecutorSprintsTest(unittest.TestCase): + def setUp(self): + self.fake = FakeJira() + self.jira = agent.JiraClient(jira_cfg(self.fake)) + self.jira.sleep = no_sleep + self.ex = agent.Executor(self.jira, dict(SETTINGS)) + self.gaps = [] + self.ex.sleep = self.gaps.append + self.fake.add("AURORA-1") + + def tearDown(self): + self.fake.stop() + + def run_cmd(self, ctype, **params): + return self.ex.run({"id": 1, "type": ctype, "params": params}) + + # --- ensure_sprint_meta --- + 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 — «имя занято», без записи + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "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.sprint_posts, [("/rest/agile/1.0/sprint/9", {"name": "T"})]) + self.fake.sprint_posts, self.fake.requests = [], [] + self.fake.board_sprints[4216] = [{"id": 9, "name": "S", "state": "future"}, + {"id": 10, "name": "T", "state": "future"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual(out["status"], "failed") + self.assertIn("занято", out["error"]) + self.assertEqual(self.fake.writes(), []) + + 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): # R-70 + out = self.run_cmd("ensure_sprint_meta", sprint="N") + self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны")) + self.assertEqual(self.fake.requests, []) -- 2.47.2 From 73cbb727adb6001822c175e356efc4a465a3c8c8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:05:46 +0400 Subject: [PATCH 12/19] =?UTF-8?q?OpenSpec:=20R-80=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 46e7a56..f5df073 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -63,3 +63,4 @@ | R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` | | R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет | | R-79 | 2026-10-24 | `ensure_fields`: настроенный `field_ids.story_points` читается напрямую, без `jira_fields()`; значение `story_points` проверяется до `GET /issue` | Результат совпадает с `jira_fields`; при частичном перекрытии не делается лишний `GET /field` (тест `test_story_points_override`) | Нет | +| R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` | -- 2.47.2 From 33d8a1e7e30e4b2d564d553d040954b423e396c6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:10:58 +0400 Subject: [PATCH 13/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20ensure=5Fsprint=5Fstate?= =?UTF-8?q?=20=E2=80=94=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=B0=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=D0=BE=D0=BC=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 74 ++++++++++++++++++++++++++- tests/fake_jira.py | 8 ++- tests/test_executor_sprints.py | 91 +++++++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 4 deletions(-) diff --git a/src/agent.py b/src/agent.py index a825aae..3a3ab41 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1114,7 +1114,7 @@ class Executor: if len(to) > 1: return self._out("failed", error=self.many_sprints(rename_to, to)) if found and to: - return self._out("failed", error="имя «%s» занято: есть и «%s», и «%s»" % (rename_to, name, rename_to)) + return self._out("failed", error="имя занято") cur = found[0] if found else (to[0] if to else None) if cur is not None and str(cur.get("state")).lower() == "closed": return self._out("failed", error="спринт закрыт") @@ -1143,6 +1143,78 @@ class Executor: self.jira.post(AGILE + "/sprint/%s" % cur["id"], diff) return self._out("done", {"sprint_id": cur["id"], "created": False}) + # --- ensure_sprint_state --------------------------------------------------- + def sprint_issues(self, sprint_id: Any) -> List[Tuple[str, Optional[str]]]: + """Все задачи спринта: (ключ, имя статуса), постранично по 50.""" + out: List[Tuple[str, Optional[str]]] = [] + start = 0 + while True: + page = self.jira.get(AGILE + "/sprint/%s/issue" % sprint_id, fields="status", startAt=start, maxResults=50) + items = page.get("issues") or [] + for it in items: + status = (((it.get("fields") or {}).get("status")) or {}).get("name") + out.append((it["key"], status)) + start += len(items) + if not items or start >= int(page.get("total") or 0): + return out + + def do_ensure_sprint_state(self, p: dict, cmd: dict) -> dict: + """Запуск (active) или завершение (closed) спринта. Все проверки — до первой записи (R-71); при завершении + незавершённые задачи (статус не в последней колонке доски) переносятся в `move_open_to` (backlog или другой + активный/будущий спринт той же доски), затем спринт закрывается. Спринт уже в нужном состоянии — ничего не пишем.""" + state = p.get("state") + if state not in ("active", "closed"): + return self._out("failed", error="state — active или closed") + board = self.sprint_board(p) + name = p["sprint"] + found = self.sprints_named(board, name) + if not found: + return self._out("failed", error="нет спринта «%s» на доске %s" % (name, board)) + if len(found) > 1: + return self._out("failed", error=self.many_sprints(name, found)) + sp = found[0] + cur = str(sp.get("state")).lower() + if cur == state: + return self._out("done", {"sprint_id": sp["id"], "moved": []}) + if state == "active": + if cur == "closed": + return self._out("failed", error="спринт закрыт") + if not sp.get("startDate") or not sp.get("endDate"): + return self._out("failed", error="у спринта нет дат: задайте ensure_sprint_meta") + self.jira.post(AGILE + "/sprint/%s" % sp["id"], + {"state": "active", "startDate": sp["startDate"], "endDate": sp["endDate"]}) + return self._out("done", {"sprint_id": sp["id"], "moved": []}) + if cur == "future": + return self._out("failed", error="спринт не начат") + to = p.get("move_open_to") + if not to: + return self._out("failed", error="move_open_to обязателен при state = closed") + target = None + if to != "backlog": + cands = [x for x in self.sprints_named(board, to) + if str(x.get("state")).lower() in ("active", "future") and x.get("id") != sp["id"]] + if len(cands) > 1: + return self._out("failed", error=self.many_sprints(to, cands)) + if not cands: + return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s" % (to, board)) + target = cands[0] + cols = self.board_columns(board) + if not cols: + return self._out("failed", error="не удалось прочитать колонки доски") + done_statuses = set(cols[-1]["statuses"]) + moved = [k for k, st in self.sprint_issues(sp["id"]) if st not in done_statuses] + first = True + for i in range(0, len(moved), 50): + if not first: + self.gap("ensure_sprint_state") + first = False + path = "/sprint/%s/issue" % target["id"] if target else "/backlog/issue" + self.jira.post(AGILE + path, {"issues": moved[i:i + 50]}) + if not first: + self.gap("ensure_sprint_state") + self.jira.post(AGILE + "/sprint/%s" % sp["id"], {"state": "closed"}) + return self._out("done", {"sprint_id": sp["id"], "moved": moved}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/fake_jira.py b/tests/fake_jira.py index fa7c9a8..50ae550 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -536,13 +536,17 @@ class FakeJira: sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1))) if method == "GET": members = self.sprint_members.get(sp["id"]) - keys = [k for k in self.search_keys(q.get("jql") or "key in ()") + pool = list(self.issues) if "jql" not in q else self.search_keys(q.get("jql") or "key in ()") + keys = [k for k in pool if (k in members if members is not None else self.issues[k]["sprint"] == sp["name"])] start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), - "issues": [{"key": k} for k in keys[start:start + maxr]]} + "issues": [{"key": k, "fields": {"status": {"name": self.issues[k]["status"]}}} + for k in keys[start:start + maxr]]} for k in body["issues"]: self.issues[k]["sprint"] = sp["name"] + if self.issues[k]["sprint_ids"] is not None: + self.issues[k]["sprint_ids"].append(sp["id"]) return 204, None if p == "/rest/agile/1.0/backlog/issue" and method == "POST": for k in body["issues"]: diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index d21fba7..7a45071 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -60,7 +60,7 @@ class ExecutorSprintsTest(unittest.TestCase): {"id": 10, "name": "T", "state": "future"}] out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) self.assertEqual(out["status"], "failed") - self.assertIn("занято", out["error"]) + self.assertEqual(out["error"], "имя занято") self.assertEqual(self.fake.writes(), []) def test_meta_repeat_after_rename(self): # Review Focus 3, R-69 @@ -89,3 +89,92 @@ class ExecutorSprintsTest(unittest.TestCase): out = self.run_cmd("ensure_sprint_meta", sprint="N") self.assertEqual((out["status"], out["error"]), ("failed", "board_id нет в команде — спринты недоступны")) self.assertEqual(self.fake.requests, []) + + # --- ensure_sprint_state --- + 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): + self.fake.board_sprints[4216] = [{"id": 4, "name": "S4", "state": "future", + "startDate": "2026-10-28T09:00:00.000+03:00", + "endDate": "2026-11-11T18:00:00.000+03:00"}] + out = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B) + self.assertEqual(out["result"], {"sprint_id": 4, "moved": []}) + self.assertEqual(self.fake.sprint_posts, [("/rest/agile/1.0/sprint/4", { + "state": "active", "startDate": "2026-10-28T09:00:00.000+03:00", "endDate": "2026-11-11T18:00:00.000+03:00"})]) + self.assertEqual(self.fake.board_sprints[4216][0]["state"], "active") + writes = len(self.fake.writes()) + again = self.run_cmd("ensure_sprint_state", sprint="S4", state="active", **B) + self.assertEqual(again["result"], {"sprint_id": 4, "moved": []}) + self.assertEqual(len(self.fake.writes()), writes) + self.fake.board_sprints[4216] = [{"id": 5, "name": "S5", "state": "future"}] + self.fake.sprint_posts, self.fake.requests = [], [] + out = self.run_cmd("ensure_sprint_state", sprint="S5", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "у спринта нет дат: задайте ensure_sprint_meta")) + self.assertEqual(self.fake.writes(), []) + + def test_state_wrong_transitions(self): + self.fake.board_sprints[4216] = [{"id": 4, "name": "F", "state": "future"}, + {"id": 1, "name": "C", "state": "closed"}] + out = self.run_cmd("ensure_sprint_state", sprint="F", state="closed", move_open_to="backlog", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "спринт не начат")) + out = self.run_cmd("ensure_sprint_state", sprint="C", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "спринт закрыт")) + self.assertEqual(self.fake.writes(), []) + + def test_state_validation(self): + self.sprint_with(0, 1) + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="future", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "state — active или closed")) + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "move_open_to обязателен при state = closed")) + out = self.run_cmd("ensure_sprint_state", sprint="Нет", state="active", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "нет спринта «Нет» на доске 4216")) + self.fake.board_sprints[4216] += [{"id": 7, "name": "Д", "state": "future"}, {"id": 8, "name": "Д", "state": "future"}] + out = self.run_cmd("ensure_sprint_state", sprint="Д", state="active", **B) + self.assertEqual(out["error"], "несколько спринтов с именем «Д»: 7, 8") + self.assertEqual(self.fake.writes(), []) + + 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(), []) -- 2.47.2 From b1716d5631fa40dc0ad492004cb23435dc8995d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:11:56 +0400 Subject: [PATCH 14/19] =?UTF-8?q?OpenSpec:=20R-81=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index f5df073..83fe50c 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -64,3 +64,4 @@ | R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет | | R-79 | 2026-10-24 | `ensure_fields`: настроенный `field_ids.story_points` читается напрямую, без `jira_fields()`; значение `story_points` проверяется до `GET /issue` | Результат совпадает с `jira_fields`; при частичном перекрытии не делается лишний `GET /field` (тест `test_story_points_override`) | Нет | | R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` | +| R-81 | 2026-10-24 | `ensure_sprint_state`: задача без статуса в ответе — незавершённая; дубликаты имени цели переноса — ошибка «несколько спринтов с именем…»; зазор `gap` ставится и перед закрытием спринта, если до этого были переносы | Решения исполнителя Task 6; безопаснее перенести лишнее, чем потерять незавершённое | Задача без статуса, на деле готовая, уедет в следующий спринт | -- 2.47.2 From 5dba9890152e7db65d54a445a8b39deb1ea5f9a2 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:18:19 +0400 Subject: [PATCH 15/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82:=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=20ensure=5Frank=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=8B=D1=88=D0=B5=20=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=B6=D0=B5=20=D0=B4=D1=80=D1=83=D0=B3=D0=BE=D0=B9?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 32 ++++++++++++++++++++++ tests/fake_jira.py | 25 +++++++++++++++++ tests/test_executor_sprints.py | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/src/agent.py b/src/agent.py index 3a3ab41..3708a14 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1215,6 +1215,38 @@ class Executor: self.jira.post(AGILE + "/sprint/%s" % sp["id"], {"state": "closed"}) return self._out("done", {"sprint_id": sp["id"], "moved": moved}) + # --- ensure_rank ----------------------------------------------------------- + def do_ensure_rank(self, p: dict, cmd: dict) -> dict: + """Положение задачи относительно другой: `before` — выше, `after` — ниже (ровно одно). Ранги читаются одним + поиском; задача уже на месте — ничего не пишем. Ошибка в entries ответа PUT — failed (R-73).""" + before, after = p.get("before"), p.get("after") + if bool(before) == bool(after): + return self._out("failed", error="нужно ровно одно из before, after") + key = p["key"] + other = before or after + if other == key: + return self._out("failed", error="задача не может стоять выше или ниже самой себя") + rid = self.jira_fields().get("rank") + if not rid: + return self._out("failed", error="поле ранга не найдено") + page = self.jira.get(API + "/search", jql="key in (%s,%s)" % (key, other), fields=rid) + ranks = {i.get("key"): (i.get("fields") or {}).get(rid) for i in page.get("issues") or []} + mine, theirs = ranks.get(key), ranks.get(other) + if isinstance(mine, str) and isinstance(theirs, str): + if (before and mine < theirs) or (after and mine > theirs): + return self._out("done", {"changed": False}) + body = {"issues": [key], "rankBeforeIssue" if before else "rankAfterIssue": other} + digits = re.search(r"(\d+)$", rid) + if digits: # id без цифрового хвоста — без rankCustomFieldId + body["rankCustomFieldId"] = int(digits.group(1)) + resp = self.jira.put(AGILE + "/issue/rank", body) + for entry in (resp or {}).get("entries") or []: # 207: ошибка по задаче — в entries, не в коде ответа + status = entry.get("status") + if status is not None and not 200 <= int(status) < 300: + errors = "; ".join(str(x) for x in entry.get("errors") or []) or "статус %s" % status + return self._out("failed", error="Jira не переранжировала %s: %s" % (key, errors)) + return self._out("done", {"changed": True}) + # --- ensure_attachment ----------------------------------------------------- def hub_file(self, sha: str, size: int) -> Optional[bytes]: """Файл вложения у хаба. None — хаб его не знает (404). Повреждённый или нечитаемый ответ — HubError с повтором, diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 50ae550..fd1eae7 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -84,6 +84,8 @@ class FakeJira: self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.sprint_seq = 500 # id следующего созданного спринта self.sprint_posts = [] # (путь, тело) каждой записи спринта: POST /sprint и POST /sprint/{id} + self.rank_bodies = [] # тела PUT /rest/agile/1.0/issue/rank + self.rank_errors = {} # ключ → текст ошибки ранжирования: ответ 207, ранг задачи не меняется self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой @@ -552,6 +554,8 @@ class FakeJira: for k in body["issues"]: self.issues[k]["sprint"] = None return 204, None + if p == "/rest/agile/1.0/issue/rank" and method == "PUT": + return self._rank(body) m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) if m: name = self.issues[m.group(1)]["sprint"] @@ -561,6 +565,27 @@ class FakeJira: return 200, {"key": m.group(1), "fields": {"sprint": sp}} return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + def _rank(self, body): + """PUT /issue/rank: задача из issues встаёт строго выше (rankBeforeIssue) или ниже (rankAfterIssue) задачи-ориентира, + ранг которой — ранг RANK_FIELD. Задача из rank_errors не меняется и получает status 400 в entries (ответ 207).""" + self.rank_bodies.append(body) + entries, failed = [], False + for k in body["issues"]: + if k in self.rank_errors: + failed = True + entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 400, + "errors": [self.rank_errors[k]]}) + continue + if body.get("rankBeforeIssue"): + ref = self.issues[body["rankBeforeIssue"]][RANK_FIELD] + self.issues[k][RANK_FIELD] = ref[:-1] + chr(ord(ref[-1]) - 1) + "z" + else: + self.issues[k][RANK_FIELD] = self.issues[body["rankAfterIssue"]][RANK_FIELD] + "m" + entries.append({"issueKey": k, "issueId": self.issues[k]["id"], "status": 204, "errors": []}) + if failed: + return 207, {"entries": entries} + return 204, None + def all_sprints(self): out = list(self.sprints) for vals in self.board_sprints.values(): diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 7a45071..375a650 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -178,3 +178,52 @@ class ExecutorSprintsTest(unittest.TestCase): 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(), []) + + # --- ensure_rank --- + 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): # у задачи нет ранга (None) — сравнивать нечего, пишем + self.fake.add("AURORA-1612") + 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": 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"]) + + def test_rank_bad_params(self): # до любых запросов к Jira — ни поиска, ни записи + 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", after="AURORA-1601") + self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after")) + out = self.run_cmd("ensure_rank", key="AURORA-1612") + self.assertEqual((out["status"], out["error"]), ("failed", "нужно ровно одно из before, after")) + out = self.run_cmd("ensure_rank", key="AURORA-1612", before="AURORA-1612") + self.assertEqual((out["status"], out["error"]), ("failed", "задача не может стоять выше или ниже самой себя")) + self.assertEqual(self.fake.writes(), []) + + 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: Ранжирование недоступно")) -- 2.47.2 From b00fa16d54fcb1c440b3e17996da4cb58f4b3bc5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:19:38 +0400 Subject: [PATCH 16/19] =?UTF-8?q?OpenSpec:=20R-82=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20v15=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/agent-v15-sprints/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 83fe50c..949c014 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -65,3 +65,4 @@ | R-79 | 2026-10-24 | `ensure_fields`: настроенный `field_ids.story_points` читается напрямую, без `jira_fields()`; значение `story_points` проверяется до `GET /issue` | Результат совпадает с `jira_fields`; при частичном перекрытии не делается лишний `GET /field` (тест `test_story_points_override`) | Нет | | R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` | | R-81 | 2026-10-24 | `ensure_sprint_state`: задача без статуса в ответе — незавершённая; дубликаты имени цели переноса — ошибка «несколько спринтов с именем…»; зазор `gap` ставится и перед закрытием спринта, если до этого были переносы | Решения исполнителя Task 6; безопаснее перенести лишнее, чем потерять незавершённое | Задача без статуса, на деле готовая, уедет в следующий спринт | +| R-82 | 2026-10-24 | `ensure_rank`: элемент `entries` с неуспешным статусом без `errors` даёт текст «статус » | Текст для этого случая планом не задан; пустой хвост после двоеточия бесполезен | Нет | -- 2.47.2 From 369357864881653579cc594539da4f7d32f3cbe3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:26:55 +0400 Subject: [PATCH 17/19] =?UTF-8?q?=D0=90=D0=B3=D0=B5=D0=BD=D1=82=20v15:=20?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20=D0=B8=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2,=20=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B3=D0=B0=20=D0=B8=20=D0=BE=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D0=BA=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия 15: команды ensure_sprint_meta, ensure_sprint_state, ensure_rank, story_points в ensure_fields, расширение refresh (rank, story_points, sprint_ids, boards). Документация: README, ARCHITECTURE, CLAUDE.md; решения R-64…R-75 перенесены в design.md; исправлен способ получения смещения дат спринта (R-68). Версии 14 в тестах test_folder и test_history подняты до 15. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 ++-- CLAUDE.md | 4 ++-- README.md | 14 +++++++++++--- openspec/changes/agent-v15-sprints/design.md | 14 +++++++++++++- src/agent.py | 2 +- tests/test_folder.py | 2 +- tests/test_history.py | 2 +- tests/test_refresh.py | 6 ++++++ 8 files changed, 37 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 59be724..e18661c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ Jira и хаба. Спека: `../sdlc-hub/openspec/changes/bridge-core/`, пл | HTTP | `Http`, `HttpError`, `JiraClient`, `HubClient`, `multipart_file`, `wire_filename` | запросы; `Http.send` — запрос с готовым телом (JSON-вызов `call` идёт через него); `JiraClient` считает вызовы, повторяет 429/503 по `Retry-After` (до 5 запросов, не более 4 ожиданий), хранит время последнего вызова; `JiraClient.upload` — вложение `multipart/form-data` (таймаут не меньше 120 с); `JiraClient.download` (через `Http.fetch_bytes`) — содержимое вложения байтами, не больше предела (`TooBig`), перенаправление на другой адрес — `RedirectAway` без перехода (`_SameOriginRedirect`); `HubClient.file` — файл вложения у хаба (base64 в JSON), `HubClient.upload_file` — скачанный файл хабу (`POST /agent/files`) | `urllib` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `sdlc-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб, затем (v13, параметр `history`) журнал изменений Jira — `refresh_history`: изменившиеся с `history.since` задачи этого прогона и стартовая выборка `history.jql`, `search` с `expand=changelog`, `POST /agent/history` пачками по 500; ответ не 2xx Jira или хаба — `history.error`/`backfill_error`, `refresh` остаётся `done`; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); `fetch_attachment` (v14) читает вложение в Jira, качает содержимое по адресу из `base_url` (предел `MAX_ATTACHMENT`, 10 МБ) и отдаёт хабу; в состоянии `refresh` у задачи — `attachments` (список без содержимого, `attachments_of`); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной; v15: поля ранга, спринта и оценки — `jira_fields` по `field_list` (`GET /field` один раз на команду; перекрытие `field_ids`), доски — `board_state` (спринты через `all_sprints`), новые `do_ensure_sprint_meta`, `do_ensure_sprint_state`, `do_ensure_rank`, `story_points` в `do_ensure_fields` | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | | Самообновление | `read_update_state`, `write_update_state`, `Updater`, `launcher_version` | `update.json`; после `claim` без команды — проверка `agent_release`, скачивание, sha256, компиляция, `selftest`, `staged`; подтверждение `ok` новой версией; без запускателя — только подсказка в лог | `HubClient`, `subprocess` | | Цикл | `Runner` | claim, пауза до команды, журнал, выполнение, отчёт до подтверждения, восстановление при старте; после `claim` без команды спрашивает `Updater`, готова версия — выход с `EXIT_UPDATE` (75) | `HubClient`, `JiraClient`, `Journal`, `Executor` | | CLI | `cmd_run`, `cmd_check`, `cmd_selftest`, `cmd_prune`, `main` | команды `run`, `check`, `selftest`, `prune` (`run` и `prune` берут `acquire_lock` на `<журнал>.lock`); обработка сигналов | `Runner`, `Journal` | @@ -43,4 +43,4 @@ agent-v9-hardening); коды агента: 75 — подмена, 73 — жур ## Открытые вопросы - HTTPS хаба с корпоративным CA: `HubClient` не принимает `ca_bundle`/`verify_ssl` (ограничение README, пункт 2). -- Спринт задач закрытых спринтов: `sprint = null` (рулинг R-6). +- Поле `sprint` задач закрытых спринтов: `sprint = null` (рулинг R-6); полная история спринтов — в `sprint_ids` (v15). diff --git a/CLAUDE.md b/CLAUDE.md index c137687..3995001 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ - `tests/_common.py` — общая настройка: путь к `src`, `SETTINGS` (как их отдаёт `/agent/settings`), `jira_cfg()`, `no_sleep()`. Паузы в тестах отключаются через `ZERO_PACING` и `no_sleep`. - Тесты разбиты по областям, а не по типам команд: `test_executor_fields.py` (`ensure_issue`, `ensure_fields`, `ensure_comment`), - `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`), + `test_executor_links.py` (`ensure_status`, `ensure_link`, `ensure_sprint`), `test_executor_sprints.py` (`ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`), `test_executor_attachments.py` (`ensure_attachment`, `fetch_attachment`), `test_refresh.py` (`refresh`), `test_runner.py` (цикл и восстановление), `test_journal.py` (журнал), `test_clients.py` (HTTP и 429), `test_update.py` (самообновление агента), `test_launcher.py` (запускатель на поддельных агентах), `test_self_update_e2e.py` (`run.py` + настоящий `agent.py` + фейки). @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. -Последний change — `../sdlc-hub/openspec/changes/attachment-fetch/` (агент v14: список вложений в `refresh`, команда `fetch_attachment`; решения агента — `openspec/changes/agent-v14-attachment-fetch/design.md`), план — `docs/superpowers/plans/2026-10-22-attachment-fetch-agent.md`. +Последний change — `../sdlc-hub/openspec/changes/sprints-backlog/` (агент v15: спринты, ранг, оценки; решения агента — `openspec/changes/agent-v15-sprints/design.md`), план — `docs/superpowers/plans/2026-10-24-sprints-backlog-agent.md`. План и спека живут в `sdlc-hub`: `openspec/changes/bridge-core/` и `docs/superpowers/plans/2026-10-11-bridge-core-hub.md`; план агента лежит здесь: `docs/superpowers/plans/2026-10-11-bridge-core-agent.md`. ## Выкатка новой версии diff --git a/README.md b/README.md index 0b31747..3ba0536 100644 --- a/README.md +++ b/README.md @@ -168,14 +168,17 @@ python3 agent.py prune | Тип | Что делает | |---|---| | `ensure_issue` | ищет задачу своей темы (`reporter = currentUser()`, за 7 дней), иначе создаёт с типом из `issue_types` хаба (`task`, `epic`, `bug`; нет настройки типа — `failed` «нет настройки issue_types.<тип>» без вызова Jira); возвращает `key` | -| `ensure_fields` | читает задачу и пишет только отличающиеся поля | +| `ensure_fields` | читает задачу и пишет только отличающиеся поля; с v15 — `story_points` (оценка; поле ищется по `GET /field`, перекрытие — `field_ids.story_points`; не найдено — `failed`, `null` — очистка) | | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | +| `ensure_sprint_meta` | (v15) имя, даты и цель спринта доски `board_id` команды; спринта нет — создаёт, есть — правит только отличающиеся поля; `rename_to` переименовывает (повтор после переименования безопасен); даты — `ГГГГ-ММ-ДД`, в Jira — 09:00 и 18:00 по локальному времени агента; `goal: ""` очищает цель, отсутствующий `goal` цель не трогает; закрытый спринт не правится — `failed` | +| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; все проверки — до первой записи; уже в нужном состоянии — `done` | +| `ensure_rank` | (v15) ставит задачу `key` выше (`before`) или ниже (`after`) другой задачи, ровно одно из двух; поле ранга ищется по `GET /field`; задача уже на месте — ничего не пишет; `entries` с неуспешным статусом в ответе Jira — `failed` | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `/secure/attachment//<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется | -| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | +| `refresh` | только читает Jira и отправляет состояние задач в хаб (с v13 — и журнал изменений по `history`, `POST /agent/history`; с v14 — список вложений задач без содержимого; с v15 — ранг (`rank`), оценка (`story_points`), история спринтов задач (`sprint_ids`, из поля спринта `search`), данные досок `boards` в первой пачке, пустая пачка с `boards` при пустой выборке); ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | Правила работы: - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. @@ -193,7 +196,7 @@ python3 agent.py prune `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 14) отправляется в каждом `claim`, в каждом отчёте +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 15) отправляется в каждом `claim`, в каждом отчёте (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в @@ -209,6 +212,11 @@ python3 agent.py prune Версия 14 — список вложений задач в состоянии `refresh` и команда `fetch_attachment` (design хаба `attachment-fetch`); перенаправления HTTP — только на адрес клиента (Jira или хаб), на другой адрес — отказ. Агент v13 на `fetch_attachment` не нужен: хаб до v14 скачивание не ставит (409 «нужен v14»). + Версия 15 — спринты, ранг и оценки: команды `ensure_sprint_meta`, `ensure_sprint_state`, `ensure_rank`, `story_points` + в `ensure_fields`; в `refresh` — `rank`, `story_points`, `sprint_ids` и данные досок `boards`. Поля ищутся по `GET /field` + (ранг и спринт — по схеме `schema.custom`, оценка — по имени «Story Points»), перекрытие — `field_ids.rank`/`sprint`/`story_points`. + Перед выкаткой проверить поля на Jira банка: имена и id могут отличаться. Агент v14 на новые типы отвечает + «неизвестный тип команды»; `min_agent_version` поднимает владелец после выкатки. Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 949c014..22fa3aa 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -32,7 +32,7 @@ `boards` прикладывается к первой пачке `POST /agent/state`; при пустой выборке — одна пачка `{issues: [], links: [], boards}`. Доски без данных (тип `null`) в `boards` не входят. 5. **Даты команд спринта.** `start_date`/`end_date` (`ГГГГ-ММ-ДД`) → `ГГГГ-ММ-ДДT09:00:00.000<смещение>` и - `ГГГГ-ММ-ДДT18:00:00.000<смещение>`, смещение — локального времени агента (`time.strftime("%z")`, вид `+0300`). + `ГГГГ-ММ-ДДT18:00:00.000<смещение>`, смещение — локального времени агента (`datetime.now().astimezone().strftime("%z")`, вид `+0300`, R-68). Сравнение с Jira — по дате (первые 10 символов `startDate`/`endDate`). 6. **`ensure_sprint_meta`.** Спринты доски `board_id` — все состояния (тот же обход, что в п. 4, кэш на команду); поиск по имени `sprint` (точное совпадение). Нет — `POST /rest/agile/1.0/sprint` `{name: rename_to or sprint, @@ -59,6 +59,18 @@ | R-61 | 2026-10-24 | `sprint_ids` из поля спринта в `search`, разбор строкового вида Jira Server (п. 2) | Обход закрытых спринтов — N запросов на спринт; поле есть у каждой задачи | Формат поля банка иной — `sprint_ids` пропадут, видно на живой проверке | | R-62 | 2026-10-24 | Kanban-бэклог — по ответу `GET /board/{id}/backlog` (п. 4) | В `configuration` Jira Server явного признака нет | `null` — у kanban нет раздела «Бэклог» | | R-63 | 2026-10-24 | Время дат спринта — 09:00 и 18:00 по локальному времени агента (п. 5) | Команда несёт только дату | Другое время в Jira — правка спринта руками | +| R-64 | 2026-10-24 | Поле ранга/спринта/оценки не найдено — ключ `rank`/`sprint_ids`/`story_points` у задач не передаётся (design агента 2–3), а не `null` (спека `agent`: «значение null у всех задач») | Сбой `GET /field` с `null` стёр бы значения в зеркале; хаб без поля сохраняет прежнее, у новой базы прежнее — `NULL` | Поле удалили в Jira — в зеркале остаются старые значения до ручной правки | +| R-65 | 2026-10-24 | Ошибка чтения спринтов доски — доска не входит в `boards` (хаб сохраняет прежние данные), причина в лог; ошибка чтения настроек при прочитанных спринтах — доска с `columns: null` | Доска из `boards` заменяет все свои спринты: `sprints: []` стёр бы историю; спека «прежние правила v12 … refresh продолжается» внутренне противоречива | Данные доски устаревают до следующего удачного `refresh` | +| R-66 | 2026-10-24 | Пустая пачка с `boards` — только когда в параметрах есть `boards` (хаб v15) и выборка пуста (`jql` без задач, `keys: []`); все ключи 400 — по-прежнему `failed` (v14, `test_all_keys_400_fails`); `pages` считает и пустую пачку | Без `boards` пустая пачка бесполезна; «ни один ключ не найден» — признак ошибки конфигурации (R-7 v4) | Хвост `refresh` по ключам удалённых задач — `failed`, данные досок в этот раз не обновятся | +| R-67 | 2026-10-24 | Любой `HttpError` на `GET /field` — поля не найдены до конца команды, следующая команда спрашивает снова; `ConnectionError` — как везде (повтор `refresh`); список полей один на команду и общий с журналом изменений (`field_id_of`) | design агента 1; один `GET /field` за `refresh` сохраняет `test_custom_field_lookup_once` | Нет | +| R-68 | 2026-10-24 | Смещение дат спринта — `datetime.now().astimezone().strftime("%z")`, не `time.strftime("%z")` (design агента 5) | `time.strftime("%z")` на Windows отдаёт имя пояса, а не `+0300` | Нет | +| R-69 | 2026-10-24 | `ensure_sprint_meta`: спринта `sprint` нет, а ровно один не закрытый спринт с именем `rename_to` есть — это он (повтор после переименования), правятся его поля; есть оба — «имя занято»; «несколько с именем» проверяется и для `rename_to` | Повтор команды не должен падать или создавать второй спринт (Anti-Goal proposal) | Нельзя создать новый спринт с именем существующего через `rename_to` — и не нужно | +| R-70 | 2026-10-24 | У `ensure_sprint_meta` и `ensure_sprint_state` `board_id` только из команды, без запасного `board_id` настроек; нет — `failed` «board_id нет в команде — спринты недоступны» | Новые типы ставит только хаб v15, он всегда дописывает доску; чужая доска из настроек опаснее отказа | Нет | +| R-71 | 2026-10-24 | Правило «несколько спринтов с именем» — и для `ensure_sprint_state`; `move_open_to` — активный или будущий спринт той же доски, не сам закрываемый, ровно один с этим именем; все проверки — до первой записи; спринт уже в нужном состоянии — `done` без проверки `move_open_to` | Повтор безопасен; ошибка параметров не оставляет спринт полузакрытым | Повтор с опечаткой в `move_open_to` после закрытия — `done`, опечатка не замечена | +| R-72 | 2026-10-24 | Спринт доски без `id`/`name` или с состоянием вне `active`/`future`/`closed` (после `lower()`) — отбрасывается с записью в лог | Хаб отвергает всю пачку (`bad_state`), `refresh` упал бы целиком | Такой спринт не виден в вебе | +| R-73 | 2026-10-24 | Ответ `PUT /issue/rank` с `entries[].status` не 2xx — `failed` «Jira не переранжировала <ключ>: » | Jira Agile сообщает о частичном отказе кодом 207 | Нет | +| R-74 | 2026-10-24 | Порядок кандидатов полей `(len(id), id)` — числовой порядок `customfield_N`, для всех трёх полей | design агента 1 («первое по id») задан только для оценки, строковое сравнение ставит `customfield_9999` после `customfield_10050` | Не то поле — перекрытие `field_ids` | +| R-75 | 2026-10-24 | Кэш полей и имён статусов — на экземпляр `Executor` (Runner пересоздаёт его при смене настроек) | «Один раз на процесс» с учётом смены `field_ids` | Нет | | R-76 | 2026-10-24 | Трейлер `Co-Authored-By` в коммитах — по плану («Claude Opus 5.5»), а не по системной подсказке сессии | План специфичнее общей подсказки | Другое имя модели в трейлере коммитов | | R-77 | 2026-10-24 | Поиск полей: оценка — только по имени «Story Points», ранг и спринт — только по `schema.custom`; при `HttpError` на `GET /field` частичное перекрытие `field_ids` сохраняется; полное перекрытие `GET /field` не делает | Решения исполнителя Task 1, согласуются с п. 1 и R-67 | Поле оценки названо иначе — задать `field_ids.story_points` | | R-78 | 2026-10-24 | Решения исполнителя Task 3: при пустых `keys` пустая пачка с `boards` уходит внутри раннего return (журнал изменений пропускается, как в v14); отброшенный спринт в логе назван по id; элементы `/status` без id/name пропускаются; в кэш `_sprints` пишутся только активные и будущие | Согласуется с брифом, R-65, R-66, R-72 и поведением v14 | Нет | diff --git a/src/agent.py b/src/agent.py index 3708a14..27b9849 100644 --- a/src/agent.py +++ b/src/agent.py @@ -33,7 +33,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional, Tuple -AGENT_VERSION = 14 +AGENT_VERSION = 15 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_UPDATE = 75 # «обновление готово»: run.py подменит agent.py на agent.py.new HERE = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/test_folder.py b/tests/test_folder.py index 41a72f9..80c3454 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -197,7 +197,7 @@ class FolderIntegrationTest(unittest.TestCase): return jira_cfg(self.fake, hub_url="folder:" + self.root, hub_token=self.hub.token) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 14) + self.assertEqual(agent.AGENT_VERSION, 15) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") diff --git a/tests/test_history.py b/tests/test_history.py index ba9f754..7ff5521 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -310,7 +310,7 @@ class RefreshHistoryTest(Base): self.assertEqual(self.changelog_queries(), []) def test_version(self): - self.assertEqual(agent.AGENT_VERSION, 14) + self.assertEqual(agent.AGENT_VERSION, 15) if __name__ == "__main__": diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 7783f35..2fee472 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -263,6 +263,12 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done") self.assertEqual(out["result"]["missing"], ["GONE-1"]) + def test_agent_version_15(self): + self.assertEqual(agent.AGENT_VERSION, 15) + self.fake.add("AURORA-1") + self.ex.run({"id": 19, "type": "refresh", "params": {"keys": ["AURORA-1"]}}) + self.assertEqual(self.hub.states[0]["agent_version"], 15) + def test_input_keys_deduplicated(self): self.fake.add("AURORA-1") out = self.ex.run({"id": 18, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-1", "", "AURORA-1"]}}) -- 2.47.2 From 9d3958e3375e61e197f48a77403dbf7491cb8dff Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:42:58 +0400 Subject: [PATCH 18/19] =?UTF-8?q?=D0=A1=D1=80=D0=B0=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=C2=AB=D0=B3=D0=BE=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=BE=C2=BB=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D0=B8=D0=B8=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=20id=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D0=BE=D0=B2,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B0=D0=BC=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C-1 финального ревью: ensure_sprint_state closed определял готовые задачи по именам через молча неполный кэш /status и мог необратимо перенести выполненные задачи. Теперь id статусов последней колонки берутся из configuration, у задач — status.id; пустой набор id — failed без записи, задача без status.id считается незавершённой. В board_columns неизвестный id один раз за команду перечитывает /status, затем пишет в лог; колонка без строкового имени пропускается с логом. Решение R-83. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- openspec/changes/agent-v15-sprints/design.md | 1 + src/agent.py | 61 +++++++++++++++----- tests/fake_jira.py | 10 +++- tests/test_executor_sprints.py | 23 ++++++++ tests/test_refresh.py | 28 +++++++-- 6 files changed, 102 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3ba0536..cc497b4 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ python3 agent.py prune | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | | `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_sprint_meta` | (v15) имя, даты и цель спринта доски `board_id` команды; спринта нет — создаёт, есть — правит только отличающиеся поля; `rename_to` переименовывает (повтор после переименования безопасен); даты — `ГГГГ-ММ-ДД`, в Jira — 09:00 и 18:00 по локальному времени агента; `goal: ""` очищает цель, отсутствующий `goal` цель не трогает; закрытый спринт не правится — `failed` | -| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; все проверки — до первой записи; уже в нужном состоянии — `done` | +| `ensure_sprint_state` | (v15) запуск (`state: active`) или завершение (`state: closed`) спринта доски `board_id` команды; при завершении незавершённые задачи переносятся в `move_open_to` (`backlog` или имя активного/будущего спринта той же доски), затем спринт закрывается; «выполнено» — по id статусов последней колонки доски (не по именам); все проверки — до первой записи; уже в нужном состоянии — `done` | | `ensure_rank` | (v15) ставит задачу `key` выше (`before`) или ниже (`after`) другой задачи, ровно одно из двух; поле ранга ищется по `GET /field`; задача уже на месте — ничего не пишет; `entries` с неуспешным статусом в ответе Jira — `failed` | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | | `fetch_attachment` | читает вложение у задачи (`fields=attachment`), качает содержимое с `/secure/attachment//<имя>` (не больше 10 МБ; перенаправление на другой адрес — `failed`, учётные данные туда не уходят) и отдаёт хабу `POST /agent/files`; в Jira не пишет. Вложение удалено — `failed` «удалено из … в Jira». Содержимое файла в журнал и лог не пишется | diff --git a/openspec/changes/agent-v15-sprints/design.md b/openspec/changes/agent-v15-sprints/design.md index 22fa3aa..e6f86f8 100644 --- a/openspec/changes/agent-v15-sprints/design.md +++ b/openspec/changes/agent-v15-sprints/design.md @@ -78,3 +78,4 @@ | R-80 | 2026-10-24 | `ensure_sprint_meta`: `goal` не передан — цель не трогается (очистка — `goal: ""`); `rename_to` равный `sprint` считается отсутствующим; «имя занято» и повтор после переименования учитывают только не закрытые спринты с именем `rename_to` (дубли `sprint` — спринты любых состояний); порядок проверок: даты → доска → дубли → `rename_to` → «спринт закрыт» → запись | Решения исполнителя Task 5; нужны идемпотентность повтора (R-69) и отсутствие записи до проверок | Хаб ждёт очистки цели через `null` — цель не очистится, передавать `""` | | R-81 | 2026-10-24 | `ensure_sprint_state`: задача без статуса в ответе — незавершённая; дубликаты имени цели переноса — ошибка «несколько спринтов с именем…»; зазор `gap` ставится и перед закрытием спринта, если до этого были переносы | Решения исполнителя Task 6; безопаснее перенести лишнее, чем потерять незавершённое | Задача без статуса, на деле готовая, уедет в следующий спринт | | R-82 | 2026-10-24 | `ensure_rank`: элемент `entries` с неуспешным статусом без `errors` даёт текст «статус » | Текст для этого случая планом не задан; пустой хвост после двоеточия бесполезен | Нет | +| R-83 | 2026-10-24 | `ensure_sprint_state`: «готово» определяется по id статусов последней колонки (из configuration) и `status.id` задач, а не по именам; пустой набор id — `failed`; в `refresh` неизвестный id статуса → один раз перечитать `/status`, затем лог | Необратимый перенос готовых задач при неполном/устаревшем кэше имён (C-1 финального ревью) | Ещё один `GET /status` при неизвестном id; отступление от п. 7 design по способу (имена → id), не по смыслу | diff --git a/src/agent.py b/src/agent.py index 27b9849..58412d3 100644 --- a/src/agent.py +++ b/src/agent.py @@ -706,6 +706,7 @@ class Executor: self._sprints = {} # str(доска) → имя → спринт; на одну команду self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor + self._status_reread = False # /status уже перечитан в этой команде (неизвестный id статуса, C-1) self._field_list = None # GET /field: список полей на одну команду self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor @@ -714,6 +715,7 @@ class Executor: self.jira.reset_counters() self._sprints = {} self._all_sprints = {} + self._status_reread = False self._field_list = None if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67) self._jira_fields = None @@ -1145,22 +1147,22 @@ class Executor: # --- ensure_sprint_state --------------------------------------------------- def sprint_issues(self, sprint_id: Any) -> List[Tuple[str, Optional[str]]]: - """Все задачи спринта: (ключ, имя статуса), постранично по 50.""" + """Все задачи спринта: (ключ, id статуса строкой или None, если Jira его не отдала), постранично по 50.""" out: List[Tuple[str, Optional[str]]] = [] start = 0 while True: page = self.jira.get(AGILE + "/sprint/%s/issue" % sprint_id, fields="status", startAt=start, maxResults=50) items = page.get("issues") or [] for it in items: - status = (((it.get("fields") or {}).get("status")) or {}).get("name") - out.append((it["key"], status)) + sid = (((it.get("fields") or {}).get("status")) or {}).get("id") + out.append((it["key"], str(sid) if sid is not None else None)) start += len(items) if not items or start >= int(page.get("total") or 0): return out def do_ensure_sprint_state(self, p: dict, cmd: dict) -> dict: """Запуск (active) или завершение (closed) спринта. Все проверки — до первой записи (R-71); при завершении - незавершённые задачи (статус не в последней колонке доски) переносятся в `move_open_to` (backlog или другой + незавершённые задачи (id статуса не среди id статусов последней колонки доски, R-83) переносятся в `move_open_to` (backlog или другой активный/будущий спринт той же доски), затем спринт закрывается. Спринт уже в нужном состоянии — ничего не пишем.""" state = p.get("state") if state not in ("active", "closed"): @@ -1198,11 +1200,13 @@ class Executor: if not cands: return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s" % (to, board)) target = cands[0] - cols = self.board_columns(board) - if not cols: + cols = self.board_config_columns(board) + done_ids = set() + if cols: + done_ids = {str(st["id"]) for st in cols[-1].get("statuses") or [] if isinstance(st, dict) and st.get("id") is not None} + if not done_ids: # колонок нет, ошибка чтения или пустая последняя колонка — не гадаем (C-1) return self._out("failed", error="не удалось прочитать колонки доски") - done_statuses = set(cols[-1]["statuses"]) - moved = [k for k, st in self.sprint_issues(sp["id"]) if st not in done_statuses] + moved = [k for k, sid in self.sprint_issues(sp["id"]) if sid is None or sid not in done_ids] first = True for i in range(0, len(moved), 50): if not first: @@ -1554,18 +1558,45 @@ class Executor: self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")} return self._status_names - def board_columns(self, board: Any) -> Optional[List[dict]]: - """Колонки доски: [{name, statuses: [имя]}]; неизвестный id статуса пропускается; ошибка Jira — лог и None.""" + def board_config_columns(self, board: Any) -> Optional[List[dict]]: + """Колонки доски как их отдаёт Jira (GET /board/{id}/configuration): [{name, statuses: [{id}]}]; + ошибка Jira — лог и None.""" try: - names = self.status_names() cfg = self.jira.get(AGILE + "/board/%s/configuration" % board) except HttpError as e: log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code)) return None - out = [] - for col in (cfg.get("columnConfig") or {}).get("columns") or []: - out.append({"name": col.get("name"), - "statuses": [names[str(st.get("id"))] for st in col.get("statuses") or [] if str(st.get("id")) in names]}) + return (cfg.get("columnConfig") or {}).get("columns") or [] + + def board_columns(self, board: Any) -> Optional[List[dict]]: + """Колонки доски для хаба: [{name, statuses: [имя]}]. Неизвестный id статуса: один раз за команду кэш имён + перечитывается (статус могли добавить после заполнения кэша), всё ещё неизвестен — пропуск с записью в лог (C-1); + колонка без строкового имени — пропуск с логом (M-3); ошибка Jira — лог и None.""" + raw = self.board_config_columns(board) + if raw is None: + return None + try: + names = self.status_names() + out = [] + for col in raw: + cname = col.get("name") + if not isinstance(cname, str): + log("доска %s: колонка %r пропущена — нет строкового имени" % (board, cname)) + continue + statuses = [] + for st in col.get("statuses") or []: + sid = str(st.get("id")) + if sid not in names and not self._status_reread: + self._status_reread, self._status_names = True, None + names = self.status_names() + if sid in names: + statuses.append(names[sid]) + else: + log("доска %s: статус %s колонки «%s» не найден" % (board, sid, cname)) + out.append({"name": cname, "statuses": statuses}) + except HttpError as e: + log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code)) + return None return out def kanban_backlog(self, board: Any) -> Optional[bool]: diff --git a/tests/fake_jira.py b/tests/fake_jira.py index fd1eae7..ff27520 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -78,6 +78,7 @@ class FakeJira: self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id} self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints self.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS) + self.statuses = dict(STATUSES) # id → имя статуса (GET /status); тесты могут добавить статус self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200) self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper @@ -486,7 +487,7 @@ class FakeJira: return 404, {"errorMessages": ["Board does not exist"]} return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]} if p == "/rest/api/2/status": - return 200, [{"id": i, "name": n} for i, n in STATUSES.items()] + return 200, [{"id": i, "name": n} for i, n in self.statuses.items()] m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p) if m: board = int(m.group(1)) @@ -543,7 +544,7 @@ class FakeJira: if (k in members if members is not None else self.issues[k]["sprint"] == sp["name"])] start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50)) return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), - "issues": [{"key": k, "fields": {"status": {"name": self.issues[k]["status"]}}} + "issues": [{"key": k, "fields": {"status": self.status_json(self.issues[k]["status"])}} for k in keys[start:start + maxr]]} for k in body["issues"]: self.issues[k]["sprint"] = sp["name"] @@ -586,6 +587,11 @@ class FakeJira: return 207, {"entries": entries} return 204, None + def status_json(self, name): + """{id, name} статуса по имени; имени нет в self.statuses — статус без id (как неполный ответ Jira).""" + ids = [i for i, n in self.statuses.items() if n == name] + return {"id": ids[0], "name": name} if ids else {"name": name} + def all_sprints(self): out = list(self.sprints) for vals in self.board_sprints.values(): diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 375a650..38c8f0d 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -179,6 +179,29 @@ class ExecutorSprintsTest(unittest.TestCase): self.assertEqual((out["status"], out["error"]), ("failed", "не удалось прочитать колонки доски")) self.assertEqual(self.fake.writes(), []) + def test_close_last_column_unknown_status_no_writes(self): # C-1: пустой набор id последней колонки + self.sprint_with(2, 1) + self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", [])] + 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(), []) + + def test_close_status_added_after_cache(self): # C-1: статус добавлен в последнюю колонку после заполнения кэша + open_keys = self.sprint_with(0, 1) + self.ex.status_names() # кэш имён заполнен до появления нового статуса + self.fake.statuses["10010"] = "Принято" + self.fake.board_columns[4216] = [("В работе", ["10003"]), ("Готово", ["10006", "10010"])] + self.fake.add("AURORA-3000", sprint="AWPCA_Sprint_3_Corrections", status="Принято") + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual(out["result"], {"sprint_id": 3, "moved": open_keys}) + self.assertEqual(self.fake.issues["AURORA-3000"]["sprint"], "AWPCA_Sprint_3_Corrections") + + def test_close_issue_without_status_id_is_open(self): # R-81: нет status.id — незавершённая + self.sprint_with(1, 0) + self.fake.add("AURORA-3001", sprint="AWPCA_Sprint_3_Corrections", status="Без id в statuses") + out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) + self.assertEqual(out["result"], {"sprint_id": 3, "moved": ["AURORA-3001"]}) + # --- ensure_rank --- def test_rank_already(self): # сценарий «Ранг уже такой» self.fake.add("AURORA-1612", customfield_10105="0|i0001:") diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 2fee472..b006d13 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -589,12 +589,30 @@ class RefreshTest(unittest.TestCase): 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"])] + def test_unknown_status_id_skipped_and_status_cached(self): # C-1: один перечитывающий GET /status за команду + лог + self.fake.board_columns[4216] = [("Одна", ["10003", "99999"]), ("Две", ["99998"])] + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(self.hub.states[0]["boards"][0]["columns"], + [{"name": "Одна", "statuses": ["Разработка"]}, {"name": "Две", "statuses": []}]) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 2) + self.assertTrue(any("4216" in l and "99999" in l and "«Одна»" in l and "не найден" in l for l in logs), logs) + self.assertTrue(any("99998" in l and "«Две»" in l for l in logs), logs) 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) + self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 3) + + def test_unknown_status_found_after_reread(self): # статус добавлен в Jira после заполнения кэша — не теряется + self.ex.status_names() + self.fake.statuses["10010"] = "Принято" + self.fake.board_columns[4216] = [("Одна", ["10003", "10010"])] + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка", "Принято"]}]) + self.assertEqual([l for l in logs if "не найден" in l], []) + + def test_column_without_string_name_skipped(self): # M-3 + self.fake.board_columns[4216] = [(None, ["10003"]), ("Ок", ["10003"])] + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Ок", "statuses": ["Разработка"]}]) + self.assertTrue(any("4216" in l and "колонка" in l and "пропущена" in l for l in logs), logs) def test_empty_jql_with_boards_sends_empty_batch(self): # R-66 out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) -- 2.47.2 From d71c2f51807d4ac7d94aba988674bb472a133c47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 19:46:46 +0400 Subject: [PATCH 19/19] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D0=BE=D0=BC=D1=83=20=D1=80=D0=B5=D0=B2=D1=8C?= =?UTF-8?q?=D1=8E=20v15:=20kanban=20=D0=B1=D0=B5=D0=B7=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BD=D0=BE=D0=BA,=20=D0=B8=D0=BC=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=BE=D0=B3=D0=BE=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=82=D0=B0,=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=B9=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=20(#4?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-1: при ошибке чтения настроек kanban-доски kanban_backlog = null и запрос бэклога не делается. M-2: ensure_sprint_meta с rename_to, имя которого занято только закрытым спринтом, — failed «имя занято». M-3: sprint_state принимает только целый id > 0 и непустое строковое name, нестроковые даты и goal дают null (как check_boards хаба). Co-Authored-By: Claude Opus 5.5 --- src/agent.py | 21 +++++++++++++++------ tests/test_executor_sprints.py | 15 +++++++++++++++ tests/test_refresh.py | 21 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/agent.py b/src/agent.py index 58412d3..f79b6b5 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1117,6 +1117,8 @@ class Executor: return self._out("failed", error=self.many_sprints(rename_to, to)) if found and to: return self._out("failed", error="имя занято") + if not to and any(str(sp.get("state")).lower() == "closed" for sp in self.sprints_named(board, rename_to)): + return self._out("failed", error="имя занято") # M-2: имя только у закрытого спринта cur = found[0] if found else (to[0] if to else None) if cur is not None and str(cur.get("state")).lower() == "closed": return self._out("failed", error="спринт закрыт") @@ -1630,18 +1632,25 @@ class Executor: @staticmethod def sprint_state(sp: dict) -> Optional[dict]: - """Спринт для хаба; без id/name или с состоянием вне active/future/closed — None (R-72).""" + """Спринт для хаба; id не целое > 0, name не непустая строка или состояние вне active/future/closed — None + (R-72, M-3: как check_boards хаба); нестроковые даты и goal — None.""" state = sp.get("state") state = state.lower() if isinstance(state, str) else None - if sp.get("id") is None or not sp.get("name") or state not in ("active", "future", "closed"): + sid, name = sp.get("id"), sp.get("name") + if (not isinstance(sid, int) or isinstance(sid, bool) or sid <= 0 or not isinstance(name, str) or not name + or state not in ("active", "future", "closed")): return None - return {"id": sp["id"], "name": sp["name"], "state": state, "start_date": sp.get("startDate"), - "end_date": sp.get("endDate"), "complete_date": sp.get("completeDate"), "goal": sp.get("goal")} + + def text(v: Any) -> Optional[str]: + return v if isinstance(v, str) else None + return {"id": sid, "name": name, "state": state, "start_date": text(sp.get("startDate")), + "end_date": text(sp.get("endDate")), "complete_date": text(sp.get("completeDate")), + "goal": text(sp.get("goal"))} def board_state(self, board: Any, kind: str) -> Optional[dict]: """Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null.""" columns = self.board_columns(board) - backlog = self.kanban_backlog(board) if kind == "kanban" else None + backlog = self.kanban_backlog(board) if kind == "kanban" and columns is not None else None # M-1 sprints: List[dict] = [] if kind == "scrum": try: @@ -1652,7 +1661,7 @@ class Executor: for sp in raw: st = self.sprint_state(sp) if st is None: - log("refresh: доска %s — спринт %r пропущен: нет id/name или состояние не active/future/closed" + log("refresh: доска %s — спринт %r пропущен: некорректные id/name или состояние не active/future/closed" % (board, sp.get("id"))) else: sprints.append(st) diff --git a/tests/test_executor_sprints.py b/tests/test_executor_sprints.py index 38c8f0d..53ab5d8 100644 --- a/tests/test_executor_sprints.py +++ b/tests/test_executor_sprints.py @@ -202,6 +202,21 @@ class ExecutorSprintsTest(unittest.TestCase): out = self.run_cmd("ensure_sprint_state", sprint="AWPCA_Sprint_3_Corrections", state="closed", move_open_to="backlog", **B) self.assertEqual(out["result"], {"sprint_id": 3, "moved": ["AURORA-3001"]}) + def test_meta_rename_to_closed_name_taken(self): # M-2 + self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}] + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "имя занято")) + self.fake.board_sprints[4216].append({"id": 9, "name": "S", "state": "future"}) + out = self.run_cmd("ensure_sprint_meta", sprint="S", rename_to="T", **B) + self.assertEqual((out["status"], out["error"]), ("failed", "имя занято")) + self.assertEqual(self.fake.writes(), []) + + def test_meta_repeat_after_rename_with_closed_namesake(self): # M-2 не ломает R-69 + self.fake.board_sprints[4216] = [{"id": 1, "name": "T", "state": "closed"}, {"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(), []) + # --- ensure_rank --- def test_rank_already(self): # сценарий «Ранг уже такой» self.fake.add("AURORA-1612", customfield_10105="0|i0001:") diff --git a/tests/test_refresh.py b/tests/test_refresh.py index b006d13..f090357 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -564,6 +564,14 @@ class RefreshTest(unittest.TestCase): 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_kanban_config_error_backlog_null(self): # M-1: columns null → kanban_backlog null, запроса бэклога нет + self.fake.boards[5120] = "kanban" + self.fake.fail_paths["/rest/agile/1.0/board/5120/configuration"] = [403] + self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}]) + b = self.hub.states[-1]["boards"][0] + self.assertEqual((b["columns"], b["kanban_backlog"]), (None, None)) + self.assertNotIn("/rest/agile/1.0/board/5120/backlog", [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] @@ -608,6 +616,19 @@ class RefreshTest(unittest.TestCase): self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка", "Принято"]}]) self.assertEqual([l for l in logs if "не найден" in l], []) + def test_board_bad_types_dropped(self): # M-3: id/name/даты/goal строже, как check_boards хаба + bad = [{"id": "5", "name": "A", "state": "active"}, {"id": True, "name": "B", "state": "active"}, + {"id": 0, "name": "C", "state": "active"}, {"id": 6, "name": 7, "state": "active"}, + {"id": 8, "name": "Ok", "state": "future", "startDate": 123, "endDate": "2026-10-28T09:00:00.000+0300", + "completeDate": [], "goal": 5}] + self.fake.board_sprints[4216] = bad + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.hub.states[0]["boards"][0]["sprints"], + [{"id": 8, "name": "Ok", "state": "future", "start_date": None, + "end_date": "2026-10-28T09:00:00.000+0300", "complete_date": None, "goal": None}]) + self.assertEqual(len([l for l in logs if "пропущен" in l]), 4, logs) + def test_column_without_string_name_skipped(self): # M-3 self.fake.board_columns[4216] = [(None, ["10003"]), ("Ок", ["10003"])] out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])) -- 2.47.2