From f3e899ec2ce4a1bfeab83b9944a81aee85aa6ec8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 19:18:04 +0400 Subject: [PATCH 1/5] =?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=D0=B0=20v12:=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BF=D0=B0=D1=80=D0=B0=D0=BC=D0=B5=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20(#35)?= 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-19-projects-agent.md | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-19-projects-agent.md diff --git a/docs/superpowers/plans/2026-10-19-projects-agent.md b/docs/superpowers/plans/2026-10-19-projects-agent.md new file mode 100644 index 0000000..23c9b18 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-projects-agent.md @@ -0,0 +1,487 @@ +# projects — агент v12: доски проектов из параметров команд — план + +> **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:** агент v12 берёт доски Jira из параметров команд: `ensure_sprint` — из `board_id` команды, `refresh` — из +`boards` (`[{id, type}]`); для досок с `type = null` определяет тип (`GET /rest/agile/1.0/board/{id}`) и возвращает его +в результате `boards`, спринты задач собирает со всех scrum-досок; без `board_id`/`boards` работает по +`settings.board_id`, как v11. + +**Architecture:** всё в `src/agent.py`, класс `Executor`. Кэш спринтов `_sprints` из одного словаря становится словарём +«доска → (имя → спринт)» и по-прежнему сбрасывается в `Executor.run` на каждую команду (а при смене настроек `Runner` +пересоздаёт `Executor`). `sprints(board)` читает активные и будущие спринты одной доски; `do_ensure_sprint` берёт доску +`p["board_id"]`, иначе `settings.board_id`. В `do_refresh` первым шагом `refresh_boards(p)` определяет типы досок с +`type = null` (`board_type(board)`, ошибка HTTP — `None` и строка в логе) и отдаёт список scrum-досок и словарь для +результата; `sprint_names(keys, boards)` обходит доски по порядку, последнее найденное имя побеждает. + +**Tech Stack:** Python 3.9.6 (`/usr/bin/python3`), только стандартная библиотека; тесты — `unittest` на фейках +`tests/fake_jira.py`, `tests/fake_hub.py`. + +**Spec:** change хаба `projects` (aep/jira-hub#61), пока в worktree хаба: +`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/changes/projects/` — +`specs/agent/spec.md` (ADDED «Доски проектов» — основное, MODIFIED «Состав и настройки агента»), +`specs/commands/spec.md` (таблица команд: `ensure_sprint.board_id`, `refresh.boards`, результат `boards`), +`design.md` (решения 4, 7, 14, 15; «Decisions made without discussion» № 6, 7, 14). Действующая спека агента — +`/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes/openspec/specs/agent/spec.md`. Решения агентской +части — `openspec/changes/agent-v12-project-boards/design.md` (создаётся в Task 3). + +**Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-agent/.wt/projects`, ветка `feat/35-projects` +(aep/jira-agent#35). Все команды — из корня worktree; основные checkout'ы `jira-agent` и `jira-hub` не трогать. + +## Global Constraints + +- Python ≥ 3.9 (на виртуалке 3.9.6, тесты — `/usr/bin/python3`), только стандартная библиотека; синтаксис 3.10+ + (`X | Y` в аннотациях функций, `match`) запрещён — `tests/test_py39.py`. Для кортежей в аннотациях — `typing.Tuple`. +- Тесты: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` — зелёным считается только весь + набор (до плана — 262 теста; набор идёт ≈2 минуты, прогон всего набора — в фоне). +- `AGENT_VERSION` SHALL быть 12. +- `ensure_sprint` SHALL брать доску из `board_id` команды; нет `board_id` (или он `null`) — из `settings.board_id`, как v11. +- `refresh` SHALL брать доски из `boards` (`[{id, type}]`, `type` — `"scrum"`, `"kanban"` или `null`). Нет ключа + `boards` (хаб до проектов) — одна доска `settings.board_id`, как v11, результат без `boards`. +- Для каждой доски с `type = null` — ровно один `GET /rest/agile/1.0/board/{id}`; тип из ответа (`type`) — `"scrum"` или + `"kanban"`, иначе `null`; ошибка запроса — `null`, строка в логе с номером доски и кодом ответа, `refresh` продолжается. +- Результат `refresh` — `boards: {"": "scrum" | "kanban" | null}` только для досок, чей тип агент запрашивал; ключ — + строка. +- Имена спринтов — со всех досок типа `scrum` (известного или только что определённого) в порядке `boards`; `kanban` и + `null` пропускаются; задача в спринтах двух досок получает последнее найденное имя (как в v11). +- `ensure_sprint` на доске без спринтов (Jira ответила ошибкой на список спринтов) — `failed` с текстом Jira. +- Коммиты на русском с `(#35)`, трейлер `Co-Authored-By: Claude Opus 5.5 `. + +## Review Focus + +1. Хаб с проектами прислал `boards: []` (ни у одного проекта нет доски), а в его настройках остался `board_id = 4216` + (решение 14 хаба) — агент не читает спринты доски из настроек, спринты задач — `null` (Task 2, + `test_empty_boards_no_settings_fallback`). +2. `GET /board/{id}` ответил 5xx (а не 404) — тип `null`, строка в логе с кодом, `refresh` завершается `done`, состояние + уходит в хаб (Task 2, `test_board_type_5xx_logged_refresh_continues`). +3. Одна и та же доска с `type = null` дважды в `boards` — один запрос типа, один ключ в результате (Task 2, + `test_duplicate_null_board_requested_once`). +4. `refresh {keys: []}` с доской неизвестного типа — тип всё равно определяется и возвращается (иначе хвост пакета без + задач оставил бы тип неизвестным) (Task 2, `test_empty_keys_still_resolves_boards`). +5. Доска, известная хабу как `scrum`, перестала отдавать спринты (стала канбаном, удалена) — `refresh` `failed` с текстом + Jira, как v11, а не молчаливое обнуление спринтов в зеркале (Task 2, `test_scrum_board_sprints_error_fails_refresh`; + решение R-32). + +--- + +### Task 1: Спринты по доске и `ensure_sprint` с `board_id` из команды + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `tests/fake_jira.py` — `FakeJira.__init__` (новые поля), маршруты Agile в `route` (стр. 352–380) +- Modify: `src/agent.py` — `Executor.__init__` и `Executor.run` (стр. 556–564, сброс `_sprints`), `Executor.sprints` + (стр. 838–851), `Executor.do_ensure_sprint` (стр. 853–873) +- Test: `tests/test_executor_links.py` + +**Interfaces:** +- Consumes: — +- Produces: + - `FakeJira.boards: Dict[int, str]` — доска → тип, по умолчанию `{4216: "scrum"}`. `GET /rest/agile/1.0/board/{id}`: + есть — 200 `{"id": id, "name": "Доска ", "type": <тип>}`, нет — 404 `{"errorMessages": ["Board does not exist"]}`. + `GET /rest/agile/1.0/board/{id}/sprint` у доски с типом `"kanban"` — 400 + `{"errorMessages": ["The board does not support sprints"]}`. + - `FakeJira.board_sprints: Dict[int, List[dict]]` — спринты доски; доски нет в словаре — общий `self.sprints` (как + раньше, чтобы старые тесты не менялись). + - `FakeJira.sprint_members: Dict[int, List[str]]` — id спринта → ключи задач; для такого спринта + `GET /sprint/{id}/issue` отдаёт эти ключи (с фильтром по `jql` `key in (…)`) вместо сравнения по имени. + - `FakeJira.all_sprints() -> List[dict]` — `self.sprints` + все списки `board_sprints`; по нему ищут спринт + `/sprint/{id}/issue` и `GET /rest/agile/1.0/issue/{key}`. + - `Executor._sprints: Dict[str, Dict[str, dict]]` — `str(доска)` → имя → спринт; `{}` в `__init__` и в начале `run`. + - `Executor.sprints(self, board: Any) -> Dict[str, dict]` — активные и будущие спринты доски `board` (постранично, как + v11), с кэшем на команду; ошибку Jira не глотает. + +- [ ] **Step 1: Фейк — доски, спринты по доске, участники спринта** + +Добавить в `FakeJira` поля и маршрут `GET /rest/agile/1.0/board/{id}` из Interfaces (регулярка `^/rest/agile/1\.0/board/(\d+)$` +не пересекается с `…/sprint`), 400 для канбан-доски в маршруте `/board/{id}/sprint`, список спринтов по `board_sprints`, +`all_sprints()` в маршрутах `/sprint/{id}/issue` и `/agile/1.0/issue/{key}`, `sprint_members` в `GET /sprint/{id}/issue`. + +Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v` +Expected: PASS (фейк по умолчанию ведёт себя как раньше). + +- [ ] **Step 2: Падающие тесты** + +В `tests/test_executor_links.py`, после `test_sprint_without_board`: + +```python + def test_sprint_board_from_params(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None}) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K") + paths = [r[1] for r in self.fake.requests] + self.assertIn("/rest/agile/1.0/board/5120/sprint", paths) + self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается + again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120) + self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False}) + + def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды» + self.ex.s["board_id"] = None + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2") + + def test_sprint_unknown_name_names_param_board(self): + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120) + self.assertEqual(out["status"], "failed") + self.assertIn("5120", out["error"]) + self.assertIn("Спринт K", out["error"]) + + def test_sprint_kanban_board_failed_with_jira_text(self): + self.fake.boards[5120] = "kanban" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertIn("does not support sprints", out["error"]) + self.assertFalse(out["retryable"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) + + def test_sprint_backlog_ignores_board(self): + self.fake.boards[5120] = "kanban" + self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120) + self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"}) + self.assertFalse(any("/board/" in r[1] for r in self.fake.requests)) +``` + +`test_sprint_without_board` (без `board_id` в команде и в настройках) не меняется: ошибка по-прежнему содержит `board_id`. + +Run: `/usr/bin/python3 -m unittest tests.test_executor_links -v` +Expected: FAIL `test_sprint_board_from_params` (идёт на доску 4216: «нет спринта «Спринт K»»), +`test_sprint_board_from_params_without_settings` («board_id не задан…»), `test_sprint_unknown_name_names_param_board` +(в тексте 4216, а не 5120); `test_sprint_kanban_board_failed_with_jira_text` и `test_sprint_backlog_ignores_board` могут +пройти уже сейчас — это допустимо, они фиксируют поведение. + +- [ ] **Step 3: Реализация** + +- `Executor._sprints = {}` в `__init__` и в начале `run` (вместо `None`). +- `sprints(self, board: Any) -> Dict[str, dict]` — тело v11, доска из аргумента, кэш `self._sprints[str(board)]`; + docstring: «Имя → спринт (active и future) доски board». +- `do_ensure_sprint`: `board = p.get("board_id") or self.s.get("board_id")` после ветки `backlog`; нет доски — + `failed` «board_id нет ни в команде, ни в настройках хаба — спринты недоступны»; дальше как v11 с `self.sprints(board)`, + в тексте «нет спринта …» — номер `board`. +- `sprint_names` пока зовёт `self.sprints(self.s["board_id"])` (переделывается в Task 2). + +Run: `/usr/bin/python3 -m unittest tests.test_executor_links tests.test_refresh -v` +Expected: PASS. + +- [ ] **Step 4: Весь набор** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +Expected: `OK`. + +- [ ] **Step 5: Commit** + +```bash +git add tests/fake_jira.py tests/test_executor_links.py src/agent.py +git commit -m "feat(agent): ensure_sprint по доске из команды, кэш спринтов по доскам (#35)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: `refresh` с досками проектов: тип доски, спринты со всех scrum-досок, результат `boards` + +**Модель исполнителя:** sonnet. + +**Files:** +- Modify: `src/agent.py` — импорт `Tuple` из `typing` (стр. 34), новые `Executor.board_type`, `Executor.refresh_boards` + (раздел `refresh`, перед `sprint_names`), `Executor.sprint_names` (стр. 1024–1043), `Executor.do_refresh` + (стр. 1045–1095) +- Test: `tests/test_refresh.py` + +**Interfaces:** +- Consumes: Task 1 — `Executor.sprints(board)`, `FakeJira.boards`, `FakeJira.board_sprints`, `FakeJira.sprint_members`. +- Produces: + - `Executor.board_type(self, board: Any) -> Optional[str]` — `GET /rest/agile/1.0/board/{board}`; `type` из ответа, + если он `"scrum"` или `"kanban"` (точное совпадение), иначе `None` и в лог + `"refresh: доска %s — тип «%s» не scrum/kanban" % (board, t)`; `HttpError` любого кода — `None` и в лог + `"refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code)`; `ConnectionError` не ловит. + - `Executor.refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]` — (scrum-доски в + порядке `boards` без повторов по `str(id)`, словарь для результата). Нет ключа `boards` — `([settings.board_id]` или + `[]`, `None)`. Есть — для каждой доски с `type is None` один `board_type`; словарь — запрошенные доски + (`str(id)` → тип), пустой словарь возвращается как `None`. Элемент без `id` пропускается. + - `Executor.sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]` — как v11, но по каждой доске + `boards` по порядку (`for board in boards: for name, sp in self.sprints(board).items(): …`); пустые `keys` или + `boards` — `{}`. + - `do_refresh`: первым действием `scrum, types = self.refresh_boards(p)` (до поиска и до раннего выхода по пустым + `keys`); `names = self.sprint_names(keys, scrum)`; если `types is not None` — `result["boards"] = types` (и в раннем + выходе по пустым `keys`). + +- [ ] **Step 1: Падающие тесты** + +В `tests/test_refresh.py` поменять вызов в `test_sprint_names_paginated` на +`self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216])` и добавить: + +```python + JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + + def refresh(self, **params): + return self.ex.run({"id": 5, "type": "refresh", "params": params}) + + def logged(self, fn): + logs, orig = [], agent.log + agent.log = logs.append + try: + return fn(), logs + finally: + agent.log = orig + + def test_two_boards_one_new(self): # сценарий «Две доски, одна новая» + self.fake.boards[5120] = "kanban" + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + paths = [r[1] for r in self.fake.requests] + self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1) + self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths) + self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_board_not_found(self): # сценарий «Доска не найдена» + self.fake.add("AURORA-1") + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"9999": None}) + self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs) + self.assertEqual(len(self.hub.states), 1) + + def test_new_scrum_board_sprints_read(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + self.fake.add("AURORA-1", sprint="Спринт K") + out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]) + self.assertEqual(out["result"]["boards"], {"5120": "scrum"}) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K") + self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests]) + + def test_old_hub_without_boards(self): # сценарий «Старый хаб» + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL) + 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") + + def test_known_boards_no_type_requests(self): + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertNotIn("boards", out["result"]) + self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests]) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_empty_boards_no_settings_fallback(self): + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[]) + self.assertEqual(out["status"], "done", out) + self.assertNotIn("boards", out["result"]) + self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests)) + self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"]) + + def test_board_type_5xx_logged_refresh_continues(self): + self.fake.add("AURORA-1") + self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": None}) + self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs) + + def test_board_other_type_is_null(self): + self.fake.boards[7000] = "simple" + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}])) + self.assertEqual(out["result"]["boards"], {"7000": None}) + self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs) + + def test_duplicate_null_board_requested_once(self): + self.fake.boards[5120] = "kanban" + out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}]) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1) + + def test_empty_keys_still_resolves_boards(self): + 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"}}) + + def test_last_found_sprint_wins_across_boards(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + self.fake.add("AURORA-1") + self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]} + both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}] + self.refresh(jql=self.JQL, boards=both) + self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K") + self.refresh(jql=self.JQL, boards=both[::-1]) + self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2") + + def test_scrum_board_sprints_error_fails_refresh(self): # R-32 + self.fake.boards[5120] = "kanban" # хаб думает, что scrum + self.fake.add("AURORA-1") + out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}]) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertEqual(self.hub.states, []) +``` + +`FakeJira.search_keys` на любую JQL без `key in` и без `reporter` отдаёт задачи с меткой `AWPCA` — `self.JQL` подходит. +В `test_board_type_5xx_logged_refresh_continues` 500 достаётся запросу типа доски потому, что `refresh_boards` — первый +вызов Jira в `do_refresh` (R-30). + +Run: `/usr/bin/python3 -m unittest tests.test_refresh -v` +Expected: FAIL — `KeyError: 'boards'` в результате, `TypeError` у `test_sprint_names_paginated` (лишний аргумент) и +запросы к доске 4216 там, где их быть не должно; `test_old_hub_without_boards` проходит уже сейчас. + +- [ ] **Step 2: Реализация** + +`board_type`, `refresh_boards`, `sprint_names(keys, boards)` и правка `do_refresh` — по Interfaces. Комментарий R-6 у +`sprint_names` сохранить. + +Run: `/usr/bin/python3 -m unittest tests.test_refresh tests.test_executor_links -v` +Expected: PASS. + +- [ ] **Step 3: Весь набор** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +Expected: `OK`. + +- [ ] **Step 4: Commit** + +```bash +git add src/agent.py tests/test_refresh.py +git commit -m "feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Версия 12, документация, change решений агента + +**Модель исполнителя:** haiku (готовый текст). + +**Files:** +- Modify: `src/agent.py:36` (`AGENT_VERSION`) +- Modify: `tests/test_folder.py:185-186` (`test_version`) +- Modify: `README.md` — абзац «Остальные настройки…» (стр. 27), строки `ensure_sprint` и `refresh` таблицы команд + (стр. 173, 175), пункт «Версия» (стр. 193–200), пункт 3 «Известных ограничений» (стр. 237–238) +- Modify: `ARCHITECTURE.md` — строка «Исполнитель» таблицы разделов (стр. 14) +- Modify: `CLAUDE.md` — раздел «Первая подсистема», строка «Последний change —» +- Create: `openspec/changes/agent-v12-project-boards/design.md` (через `openspec new change agent-v12-project-boards`) + +**Interfaces:** +- Consumes: Task 1–2. +- Produces: `AGENT_VERSION = 12`. + +- [ ] **Step 1: Падающий тест версии** + +В `tests/test_folder.py`, `test_version`: `assertEqual(agent.AGENT_VERSION, 11)` → `assertEqual(agent.AGENT_VERSION, 12)`. + +Run: `/usr/bin/python3 -m unittest tests.test_folder -v` +Expected: FAIL — `AssertionError: 11 != 12`. + +- [ ] **Step 2: Версия** + +В `src/agent.py` `AGENT_VERSION = 11` → `AGENT_VERSION = 12`. + +Run: `/usr/bin/python3 -m unittest tests.test_folder -v` +Expected: PASS. + +- [ ] **Step 3: README** + +- Стр. 27: `(id полей, типы задач, карта переходов, типы связей, доска, темп)` → `(id полей, типы задач, карта переходов, + типы связей, темп)`, а после предложения дописать: «Доски Jira (с v12) приходят в параметрах команд: `ensure_sprint` — + `board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд старого хаба.» +- Строка `ensure_sprint`: `переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и + будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira`. +- Строка `refresh`: дописать в конец ячейки `; доски с неизвестным типом (`boards`, `type = null`) — запрос + `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок`. +- Пункт «Версия»: `(сейчас 11)` → `(сейчас 12)`; после строки «Агент v10 на `ensure_attachment` …» дописать строкой того + же отступа: + +```markdown + Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате + `refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами + выкатывается только после v12 (design хаба `projects`, решение 14). +``` + +- Пункт 3 ограничений: `**Спринт в `refresh` — только активные и будущие спринты доски.**` → `**Спринт в `refresh` — + только активные и будущие спринты scrum-досок.**`; после «У задач закрытых спринтов `sprint = null`.» дописать: «Задачи + канбан-досок и досок неизвестного типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по + списку `boards`. Если scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом + Jira (R-32).» + +- [ ] **Step 4: ARCHITECTURE.md и CLAUDE.md** + +- `ARCHITECTURE.md`, строка «Исполнитель», колонка «Ответственность» — дописать в конец: `; доски — из параметров команд + (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной`. +- `CLAUDE.md`, строку `Последний change — …` заменить на: + +```markdown +Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`. +``` + +- [ ] **Step 5: Change решений агента** + +Run: `openspec new change agent-v12-project-boards` +Expected: создан каталог `openspec/changes/agent-v12-project-boards/`. + +Создать `openspec/changes/agent-v12-project-boards/design.md`; таблица — те же строки R-30…R-35, что в разделе +«Decisions made without discussion» этого плана; решения, принятые при исполнении, контроллер дописывает следующими +номерами, начиная с R-36: + +```markdown +# agent-v12-project-boards — решения агента + +Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35). +Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки +агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`). + +## Decisions made without discussion + +Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»; +для агента — № 6, 7, 14); здесь — только решения агентской части. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +<строки R-30…R-35 из плана> +``` + +- [ ] **Step 6: Весь набор и синтаксис 3.9** + +Run: `/usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t .` +Expected: `OK` (262 + новые из Task 1–2). + +Run: `/usr/bin/python3 -m unittest tests.test_py39 -v` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add src/agent.py tests/test_folder.py README.md ARCHITECTURE.md CLAUDE.md openspec/changes/agent-v12-project-boards +git commit -m "chore(agent): версия 12, README и решения агента для досок проектов (#35)" -m "Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +## Decisions made without discussion + +Нумерация рулингов агентской части — с R-30, чтобы не пересечься с рулингами исполнения хабовой части change. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` | +| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) | +| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба | +| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет | +| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе | +| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` | + +## После плана + +- Финальное ревью ветки (opus), затем PR в aep/jira-agent со ссылкой на #35 и на change `projects` хаба. Агент + сливается и публикуется первым (design хаба, решения 14 и 15). +- После merge: `uv run --project ../jira-hub jira-hub agent publish src/agent.py` из основного checkout `jira-agent` на + main — пишет в каталог живого хаба, выполняется только по отдельной команде владельца; проверка — MCP `agent_status` + «обновление: ok v11 → v12». Затем — выкатка хаба (шаги 2–4 решения 14 хаба). -- 2.47.2 From fc87f54cf9ee4eed5c06406d99c4ea08f2f3abf0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:12:30 +0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(agent):=20ensure=5Fsprint=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B5=20=D0=B8=D0=B7=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D1=8B,=20=D0=BA=D1=8D=D1=88=20?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=D0=BC=20(#35)?= 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 | 26 +++++++++++----------- tests/fake_jira.py | 28 ++++++++++++++++++++---- tests/test_executor_links.py | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/src/agent.py b/src/agent.py index 78240fa..0295a29 100644 --- a/src/agent.py +++ b/src/agent.py @@ -557,11 +557,11 @@ class Executor: self.jira, self.s, self.hub = jira, settings, hub self.sleep = time.sleep self._symmetric = None - self._sprints = None + self._sprints = {} # str(доска) → имя → спринт; на одну команду def run(self, cmd: dict) -> dict: self.jira.reset_counters() - self._sprints = None + self._sprints = {} handler = getattr(self, "do_" + str(cmd.get("type")), None) if handler is None: return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type")) @@ -835,11 +835,10 @@ class Executor: return self._out("done", {"created": True, "removed": removed}) # --- ensure_sprint --------------------------------------------------------- - def sprints(self) -> Dict[str, dict]: - """Имя → спринт (active и future) доски board_id; без доски — пусто.""" - if self._sprints is None: + def sprints(self, board: Any) -> Dict[str, dict]: + """Имя → спринт (active и future) доски board; без доски — пусто.""" + if str(board) not in self._sprints: out, start = {}, 0 - board = self.s.get("board_id") while board: page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50) for sp in page.get("values", []): @@ -847,8 +846,8 @@ class Executor: if page.get("isLast", True) or not page.get("values"): break start += len(page["values"]) - self._sprints = out - return self._sprints + self._sprints[str(board)] = out + return self._sprints[str(board)] def do_ensure_sprint(self, p: dict, cmd: dict) -> dict: key, want = p["key"], p["sprint"] @@ -860,14 +859,15 @@ class Executor: return self._out("done", {"sprint": None, "changed": False}) self.jira.post(AGILE + "/backlog/issue", {"issues": [key]}) return self._out("done", {"sprint": None, "from": cur}) - if not self.s.get("board_id"): - return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны") + board = p.get("board_id") or self.s.get("board_id") # без доски в команде — хаб до проектов (v11) + if not board: + return self._out("failed", error="board_id нет ни в команде, ни в настройках хаба — спринты недоступны") if cur == want: return self._out("done", {"sprint": want, "changed": False}) - sp = self.sprints().get(want) + sp = self.sprints(board).get(want) if not sp: return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s" - % (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—")) + % (want, board, ", ".join(sorted(self.sprints(board))) or "—")) self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]}) return self._out("done", {"sprint": want, "from": cur}) @@ -1027,7 +1027,7 @@ class Executor: names = {} if not keys or not self.s.get("board_id"): return names - for name, sp in self.sprints().items(): + for name, sp in self.sprints(self.s["board_id"]).items(): for i in range(0, len(keys), 100): chunk = keys[i:i + 100] start = 0 diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 87bd154..7e08bc4 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -69,6 +69,9 @@ class FakeJira: self.comments = {} self.links = [] 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_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи) self.seq, self.link_seq, self.comment_seq = 1000, 100, 10 self.id_seq = 10000 self.fail_next = [] # [(код, {заголовки})] — следующие ответы с ошибкой @@ -349,17 +352,28 @@ class FakeJira: tid = int(body["transition"]["id"]) - 1 self.issues[key]["status"] = trs[tid][1] return 204, None + m = re.match(r"^/rest/agile/1\.0/board/(\d+)$", p) + if m: + board = int(m.group(1)) + if board not in self.boards: + return 404, {"errorMessages": ["Board does not exist"]} + return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]} m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) if m: + board = int(m.group(1)) + 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.sprints if s["state"] in states] + vals = [s for s in self.board_sprints.get(board, self.sprints) if 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) if m: - sp = next(s for s in self.sprints if s["id"] == int(m.group(1))) + sp = next(s for s in self.all_sprints() if s["id"] == int(m.group(1))) if method == "GET": - keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]] + members = self.sprint_members.get(sp["id"]) + keys = [k for k in self.search_keys(q.get("jql") or "key in ()") + 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]]} @@ -373,12 +387,18 @@ class FakeJira: m = re.match(r"^/rest/agile/1\.0/issue/([^/]+)$", p) if m: name = self.issues[m.group(1)]["sprint"] - sp = next((s for s in self.sprints if s["name"] == name), None) + sp = next((s for s in self.all_sprints() if s["name"] == name), None) if sp is None: # как Jira банка (13.10.2026): без спринта ключа fields в ответе нет return 200, {"key": m.group(1)} return 200, {"key": m.group(1), "fields": {"sprint": sp}} return 404, {"errorMessages": ["нет маршрута %s %s" % (method, p)]} + def all_sprints(self): + out = list(self.sprints) + for vals in self.board_sprints.values(): + out += vals + return out + def _type_name(self, ref): ref = ref or {} if ref.get("name"): diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py index 2b41fbc..228ad88 100644 --- a/tests/test_executor_links.py +++ b/tests/test_executor_links.py @@ -199,6 +199,48 @@ class ExecutorLinksTest(unittest.TestCase): self.assertEqual(out["status"], "failed") self.assertIn("board_id", out["error"]) + def test_sprint_board_from_params(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"], {"sprint": "Спринт K", "from": None}) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт K") + paths = [r[1] for r in self.fake.requests] + self.assertIn("/rest/agile/1.0/board/5120/sprint", paths) + self.assertNotIn("/rest/agile/1.0/board/4216/sprint", paths) # settings.board_id = 4216 не трогается + again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт K", board_id=5120) + self.assertEqual(again["result"], {"sprint": "Спринт K", "changed": False}) + + def test_sprint_board_from_params_without_settings(self): # сценарий «Спринт по доске из команды» + self.ex.s["board_id"] = None + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=4216) + self.assertEqual(out["status"], "done", out) + self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2") + + def test_sprint_unknown_name_names_param_board(self): + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120) + self.assertEqual(out["status"], "failed") + self.assertIn("5120", out["error"]) + self.assertIn("Спринт K", out["error"]) + + def test_sprint_kanban_board_failed_with_jira_text(self): + self.fake.boards[5120] = "kanban" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertIn("does not support sprints", out["error"]) + self.assertFalse(out["retryable"]) + self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"]) + + def test_sprint_backlog_ignores_board(self): + self.fake.boards[5120] = "kanban" + self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1" + out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog", board_id=5120) + self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"}) + self.assertFalse(any("/board/" in r[1] for r in self.fake.requests)) + if __name__ == "__main__": unittest.main() -- 2.47.2 From 9dd3dd7d9d2122ac1d7717fac9a9accbf5d98da5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:16:52 +0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(agent):=20refresh=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D0=B2=20=E2=80=94=20=D1=82=D0=B8=D0=BF=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B8,=20=D1=81=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D1=81=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20?= =?UTF-8?q?scrum-=D0=B4=D0=BE=D1=81=D0=BE=D0=BA,=20=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82=20boards=20(#35)?= 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 | 81 ++++++++++++++++++++++-------- tests/test_refresh.py | 112 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 21 deletions(-) diff --git a/src/agent.py b/src/agent.py index 0295a29..ac243e5 100644 --- a/src/agent.py +++ b/src/agent.py @@ -31,7 +31,7 @@ import urllib.parse import urllib.request import uuid from datetime import datetime, timezone -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Tuple AGENT_VERSION = 11 EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) @@ -1021,36 +1021,75 @@ class Executor: out.append(self.comment_state(c)) return out - def sprint_names(self, keys: List[str]) -> Dict[str, str]: - # Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, - # поэтому у задач закрытых спринтов sprint = None (принятое ограничение). + def board_type(self, board: Any) -> Optional[str]: + """Тип доски Jira: scrum или kanban; иное и ошибка Jira — None с записью в лог (refresh продолжается).""" + try: + t = self.jira.get(AGILE + "/board/%s" % board).get("type") + except HttpError as e: + log("refresh: доска %s — тип не определён (HTTP %d)" % (board, e.code)) + return None + if t in ("scrum", "kanban"): + return t + 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.""" + if "boards" not in p: + board = self.s.get("board_id") + return ([board] if board else []), None + scrum: List[Any] = [] + types: Dict[str, Optional[str]] = {} + seen = set() + for b in p.get("boards") or []: + bid = (b or {}).get("id") + if bid is None or str(bid) in seen: + continue + seen.add(str(bid)) + t = b.get("type") + if t is None: + t = types[str(bid)] = self.board_type(bid) + if t == "scrum": + scrum.append(bid) + return scrum, (types or None) + + def sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]: + # Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет, + # поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок — + # побеждает последнее найденное имя. names = {} - if not keys or not self.s.get("board_id"): + if not keys: return names - for name, sp in self.sprints(self.s["board_id"]).items(): - for i in range(0, len(keys), 100): - chunk = keys[i:i + 100] - start = 0 - while True: - page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), - fields="key", startAt=start, maxResults=50) - got = page.get("issues") or [] - for it in got: - names[it["key"]] = name - start += len(got) - if not got or start >= int(page.get("total") or 0): - break + for board in boards: + for name, sp in self.sprints(board).items(): + for i in range(0, len(keys), 100): + chunk = keys[i:i + 100] + start = 0 + while True: + page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), + fields="key", startAt=start, maxResults=50) + got = page.get("issues") or [] + for it in got: + names[it["key"]] = name + start += len(got) + if not got or start >= int(page.get("total") or 0): + break return names def do_refresh(self, p: dict, cmd: dict) -> dict: missing: List[str] = [] errors: List[HttpError] = [] + scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys 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: - return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0}) + empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0} + if types is not None: + empty["boards"] = types + return self._out("done", empty) raws = [] for i in range(0, len(keys), 100): raws += self.search_tolerant(keys[i:i + 100], missing, errors) @@ -1068,7 +1107,7 @@ class Executor: for i in range(0, len(absent), 100): neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors - names = self.sprint_names([r["key"] for r in everything]) + names = self.sprint_names([r["key"] for r in everything], scrum) issues, links, seen = [], [], set() for r in everything: st = self.to_state(r) @@ -1095,6 +1134,8 @@ class Executor: result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} if missing: result["missing"] = list(dict.fromkeys(missing))[:50] + if types is not None: + result["boards"] = types return self._out("done", result) diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 33bc830..01843f0 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -266,7 +266,7 @@ class RefreshTest(unittest.TestCase): def test_sprint_names_paginated(self): for i in range(1, 121): self.fake.add("AURORA-%d" % i, sprint="Спринт 2") - names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)]) + names = self.ex.sprint_names(["AURORA-%d" % i for i in range(1, 101)], [4216]) self.assertEqual(len(names), 100) pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"] self.assertEqual(len(pages), 2) @@ -303,5 +303,115 @@ class RefreshTest(unittest.TestCase): self.assertNotIn("peer", out) + JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + + def refresh(self, **params): + return self.ex.run({"id": 5, "type": "refresh", "params": params}) + + def logged(self, fn): + logs, orig = [], agent.log + agent.log = logs.append + try: + return fn(), logs + finally: + agent.log = orig + + def test_two_boards_one_new(self): # сценарий «Две доски, одна новая» + self.fake.boards[5120] = "kanban" + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + paths = [r[1] for r in self.fake.requests] + self.assertEqual(paths.count("/rest/agile/1.0/board/5120"), 1) + self.assertNotIn("/rest/agile/1.0/board/5120/sprint", paths) + self.assertNotIn("/rest/agile/1.0/board/4216", paths) # тип известен — не спрашиваем + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_board_not_found(self): # сценарий «Доска не найдена» + self.fake.add("AURORA-1") + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": None}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"9999": None}) + self.assertTrue(any("9999" in l and "HTTP 404" in l for l in logs), logs) + self.assertEqual(len(self.hub.states), 1) + + def test_new_scrum_board_sprints_read(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + self.fake.add("AURORA-1", sprint="Спринт K") + out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}]) + self.assertEqual(out["result"]["boards"], {"5120": "scrum"}) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт K") + self.assertNotIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests]) + + def test_old_hub_without_boards(self): # сценарий «Старый хаб» + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL) + 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") + + def test_known_boards_no_type_requests(self): + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]) + self.assertNotIn("boards", out["result"]) + self.assertNotIn("/rest/agile/1.0/board/4216", [r[1] for r in self.fake.requests]) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_empty_boards_no_settings_fallback(self): + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[]) + self.assertEqual(out["status"], "done", out) + self.assertNotIn("boards", out["result"]) + self.assertFalse(any("/rest/agile/1.0/board/" in r[1] for r in self.fake.requests)) + self.assertIsNone(self.hub.states[0]["issues"][0]["sprint"]) + + def test_board_type_5xx_logged_refresh_continues(self): + self.fake.add("AURORA-1") + self.fake.fail_next.append((500, {})) # первый вызов Jira в refresh — тип доски + out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": None}])) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": None}) + self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs) + + def test_board_other_type_is_null(self): + self.fake.boards[7000] = "simple" + out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 7000, "type": None}])) + self.assertEqual(out["result"]["boards"], {"7000": None}) + self.assertTrue(any("7000" in l and "simple" in l for l in logs), logs) + + def test_duplicate_null_board_requested_once(self): + self.fake.boards[5120] = "kanban" + out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}, {"id": 5120, "type": None}]) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + self.assertEqual([r[1] for r in self.fake.requests].count("/rest/agile/1.0/board/5120"), 1) + + def test_empty_keys_still_resolves_boards(self): + 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"}}) + + def test_last_found_sprint_wins_across_boards(self): + self.fake.boards[5120] = "scrum" + self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] + self.fake.add("AURORA-1") + self.fake.sprint_members = {2: ["AURORA-1"], 7: ["AURORA-1"]} + both = [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}] + self.refresh(jql=self.JQL, boards=both) + self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт K") + self.refresh(jql=self.JQL, boards=both[::-1]) + self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2") + + def test_scrum_board_sprints_error_fails_refresh(self): # R-32 + self.fake.boards[5120] = "kanban" # хаб думает, что scrum + self.fake.add("AURORA-1") + out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}]) + self.assertEqual(out["status"], "failed") + self.assertIn("HTTP 400", out["error"]) + self.assertEqual(self.hub.states, []) + + if __name__ == "__main__": unittest.main() -- 2.47.2 From 719d4bceef2bb25f6b009f3d13920c3b00a87817 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:21:25 +0400 Subject: [PATCH 4/5] =?UTF-8?q?chore(agent):=20=D0=B2=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=8F=2012,=20README=20=D0=B8=20=D1=80=D0=B5=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=BE=D1=81=D0=BE=D0=BA=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 2 +- CLAUDE.md | 2 +- README.md | 21 ++++++++++++------- .../agent-v12-project-boards/.openspec.yaml | 2 ++ .../agent-v12-project-boards/design.md | 19 +++++++++++++++++ src/agent.py | 2 +- tests/test_folder.py | 2 +- 7 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 openspec/changes/agent-v12-project-boards/.openspec.yaml create mode 100644 openspec/changes/agent-v12-project-boards/design.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 48c2a56..0bc527b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,7 +11,7 @@ Jira и хаба. Спека: `../jira-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 с); `HubClient.file` — файл вложения у хаба (base64 в JSON) | `urllib` | | Папка | `FolderTransport` | транспорт `folder:` к хабу через общую папку: `req/.json` → реле хаба → `resp/.json`; ошибки как у `Http`; `HubClient` делегирует ему `call` (спека `jira-hub/openspec/changes/folder-transport/`) | `os`, `uuid` | | Журнал | `Journal`, `_parse_at` | дозапись `journal.jsonl`: `started` → `finished` → `acked`; восстановление; `prune` (из CLI — под блокировкой журнала) | файловая система | -| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором) | `JiraClient`, `HubClient` (`refresh` и файл в `ensure_attachment`), настройки хаба | +| Исполнитель | `Executor` и `do_<тип>` | по одному методу на тип команды: читает Jira, пишет только разницу; `refresh` читает и отправляет состояние в хаб; `ensure_attachment` берёт файл вложения у хаба (`hub_file`: sha256 и размер, повреждение — `HubError` с повтором); доски — из параметров команд (`ensure_sprint.board_id`, `refresh.boards`, тип доски — `board_type`), `board_id` настроек — запасной | `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` | diff --git a/CLAUDE.md b/CLAUDE.md index 4bfc0e6..f634c32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ ## Первая подсистема Исполнитель команд и цикл агента (спека `agent/spec.md`, `commands/spec.md`) — реализованы целиком: все типы команд, журнал, восстановление после перезапуска, CLI `run|check|selftest|prune`, самообновление через `run.py`. -Последний change — `../jira-hub/openspec/changes/bug-and-attachments/` (агент v11: тип «Ошибка», `ensure_attachment`, устойчивое дочитывание комментариев; решения агента — `openspec/changes/agent-v11-bug-attachments/design.md`), план — `docs/superpowers/plans/2026-10-15-bug-and-attachments-agent.md`. +Последний change — `../jira-hub/openspec/changes/projects/` (агент v12: доски проектов из параметров команд, тип доски в `refresh`; решения агента — `openspec/changes/agent-v12-project-boards/design.md`), план — `docs/superpowers/plans/2026-10-19-projects-agent.md`. План и спека живут в `jira-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 b2c6808..9862c4c 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ 3. `agent.json` содержит учётные данные Jira. Никуда не копировать и никуда не отправлять, кроме этой виртуалки. Хаб получает от агента только результаты команд и состояние задач, без паролей и токенов Jira. -Остальные настройки (id полей, типы задач, карта переходов, типы связей, доска, темп) агент получает от хаба при -старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. +Остальные настройки (id полей, типы задач, карта переходов, типы связей, темп) агент получает от хаба при +старте и при каждом изменении `settings_version`; в `agent.json` их быть не должно. Доски Jira (с v12) приходят в +параметрах команд: `ensure_sprint` — `board_id`, `refresh` — `boards`; `board_id` из настроек — запасной для команд +старого хаба. ## Проверка @@ -170,9 +172,9 @@ python3 agent.py prune | `ensure_comment` | комментарий с маркером в начале: обновляет, если текст изменился, иначе создаёт | | `ensure_status` | идёт по пути статусов, на каждом шаге выбирает переход; нет перехода — `failed` со списком доступных | | `ensure_link` | создаёт связь; конфликт без `replace` — `failed`; с `replace` сначала удаляет лишнее | -| `ensure_sprint` | переносит в спринт доски (активные и будущие) или в бэклог | +| `ensure_sprint` | переносит в спринт доски `board_id` команды (без него — доски из настроек хаба; активные и будущие спринты) или в бэклог; доска без спринтов (канбан) — `failed` с текстом Jira | | `ensure_attachment` | ищет у задачи вложение с тем же именем и размером — есть: ничего не делает; нет: берёт файл у хаба (`/agent/files/{sha256}`), сверяет sha256 и размер и загружает в Jira (`multipart/form-data`); файл удалён с хаба — `failed` «загрузите заново»; повреждённый файл — повтор, как обрыв связи с хабом. Содержимое файла в журнал и лог не пишется | -| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет | +| `refresh` | только читает Jira и отправляет состояние задач в хаб; ничего не пишет; доски с неизвестным типом (`boards`, `type = null`) — запрос `/rest/agile/1.0/board/{id}`, тип в результате `boards`; спринты — со всех scrum-досок | Правила работы: - **Проверь, потом сделай.** Каждый тип сначала читает текущее состояние и записывает только разницу. @@ -190,7 +192,7 @@ python3 agent.py prune `finished`, ждёт не меньше 30 с и повторяет команду через проверку («проверь, потом сделай») до 3 раз. Счётчики суммируются. После исчерпания — `failed` «результат неизвестен (обрыв связи с Jira), повтор безопасен». При остановке по сигналу команду подхватит восстановление. -- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 11) отправляется в каждом `claim`, в каждом отчёте +- **Версия.** Константа `AGENT_VERSION` в `agent.py` (сейчас 12) отправляется в каждом `claim`, в каждом отчёте (`agent_version`) и в `/agent/state`. Версия 4 — сравнение текстов без различия `\r\n`/`\n`, полная пауза при времени журнала в будущем, `claim` без оценки из журнала, `prune` под блокировкой. Версия 5 — самообновление через `run.py`. Версия 9 — код 73 при занятом журнале, `OSError` при подготовке — временный сбой, проверка формата публикации, источник ошибки хаба в @@ -198,6 +200,9 @@ python3 agent.py prune Версия 10 — комментарии задач в состоянии `refresh`. Версия 11 — тип задачи из `issue_types` с ошибкой конфигурации (тип `bug` — «Ошибка»), команда `ensure_attachment`, устойчивое дочитывание комментариев (M-1, M-2 из #30). Агент v10 на `ensure_attachment` отвечает `failed` «неизвестный тип команды»; хаб после выкатки ставит `min_agent_version = 11`. + Версия 12 — доски проектов из параметров команд (`ensure_sprint.board_id`, `refresh.boards`), тип доски в результате + `refresh`, спринты со всех scrum-досок; без этих параметров — `board_id` из настроек, как v11. Хаб с проектами + выкатывается только после v12 (design хаба `projects`, решение 14). Если хаб назначил `min_agent_version` выше, он пишет в `agent_status.warning`: «обновите агента»; команды при этом выдаются по-прежнему. @@ -234,8 +239,10 @@ python3 agent.py prune следующем `claim` — агент выполнит её через проверку. 2. **HTTPS хаба с корпоративным CA не поддержан.** `HubClient` работает без `ca_bundle` и `verify_ssl`: `hub_url` должен быть `http://`. Для Jira `verify_ssl` и `ca_bundle` работают. -3. **Спринт в `refresh` — только активные и будущие спринты доски.** Поле `sprint` берётся из Agile API по - этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. +3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по + этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного + типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если + scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом Jira (R-32). 4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). diff --git a/openspec/changes/agent-v12-project-boards/.openspec.yaml b/openspec/changes/agent-v12-project-boards/.openspec.yaml new file mode 100644 index 0000000..c1bc902 --- /dev/null +++ b/openspec/changes/agent-v12-project-boards/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-19 diff --git a/openspec/changes/agent-v12-project-boards/design.md b/openspec/changes/agent-v12-project-boards/design.md new file mode 100644 index 0000000..b575c52 --- /dev/null +++ b/openspec/changes/agent-v12-project-boards/design.md @@ -0,0 +1,19 @@ +# agent-v12-project-boards — решения агента + +Спека и дизайн изменения — в `jira-hub/openspec/changes/projects/` (issue aep/jira-hub#61, aep/jira-agent#35). +Агентская часть — решения 7, 14, 15 дизайна хаба и требования `agent` «Доски проектов» (ADDED) и «Состав и настройки +агента» (MODIFIED); контракт команд — `commands` (`ensure_sprint.board_id`, `refresh.boards`, результат `boards`). + +## Decisions made without discussion + +Решения 1–21 записаны в `jira-hub/openspec/changes/projects/design.md` (раздел «Decisions made without discussion»; +для агента — № 6, 7, 14); здесь — только решения агентской части. + +| Маркер | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| R-30 | 2026-10-19 | Типы досок определяются первым шагом `refresh` — до поиска и в том числе при пустых `keys` | Спека: «для каждой доски с `type = null` агент SHALL запросить» — без условий; хвост пакета `refresh {keys: []}` не должен оставлять тип неизвестным | Лишний `GET /board/{id}` на доску неизвестного типа, если поиск затем упадёт; результат с типом при этом теряется до следующего `refresh` | +| R-31 | 2026-10-19 | Любой `HttpError` на `GET /board/{id}` (в том числе 5xx) — тип `null` и строка в логе, `refresh` продолжается; `ConnectionError` не ловится — весь `refresh` `failed` с повтором, как при обрыве связи с Jira | Спека: «ошибка запроса — в лог, `refresh` продолжается»; `null` тип в хабе не меняет, следующий `refresh` спросит снова; обрыв связи — не свойство доски | Временный 5xx откладывает определение типа до следующего `refresh` (автообновление) | +| R-32 | 2026-10-19 | Ошибка чтения спринтов scrum-доски (известной или только что определённой) в `refresh` не глотается: `refresh` `failed` с текстом Jira, как v11 | Пропуск доски записал бы в зеркало `sprint = null` всем её задачам; спека о такой ошибке молчит | Одна сломанная доска (удалена, стала канбаном) останавливает обновление зеркала всех проектов, пока владелец не поправит доску в базе хаба | +| R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет | +| R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе | +| R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` | diff --git a/src/agent.py b/src/agent.py index ac243e5..4e704bc 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 = 11 +AGENT_VERSION = 12 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 75b8221..f738461 100644 --- a/tests/test_folder.py +++ b/tests/test_folder.py @@ -183,7 +183,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, 11) + self.assertEqual(agent.AGENT_VERSION, 12) def test_runner_through_folder(self): self.fake.add("AURORA-1", duedate="2026-10-20") -- 2.47.2 From ced48cf9db07a5d8c01351c025701af45ed55a2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:31:59 +0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(agent):=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20refresh=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BE=D1=81=D0=BA=D1=83,=20=D0=B4=D0=BE=D1=81=D0=BA?= =?UTF-8?q?=D0=B0-=D0=BA=D0=B0=D0=BD=D0=B1=D0=B0=D0=BD=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20?= =?UTF-8?q?(I-1,=20M-2..M-5,=20#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- .../agent-v12-project-boards/.openspec.yaml | 1 + .../agent-v12-project-boards/design.md | 1 + src/agent.py | 26 +++++++++++++++---- tests/fake_jira.py | 2 ++ tests/test_executor_links.py | 1 + tests/test_refresh.py | 23 +++++++++++++--- 7 files changed, 47 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 9862c4c..59577e2 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,8 @@ python3 agent.py prune 3. **Спринт в `refresh` — только активные и будущие спринты scrum-досок.** Поле `sprint` берётся из Agile API по этим спринтам (рулинг R-6). У задач закрытых спринтов `sprint = null`. Задачи канбан-досок и досок неизвестного типа — тоже `null`. Задача в спринтах двух досок получает имя спринта последней по списку `boards`. Если - scrum-доска не отдала спринты (удалена, стала канбаном), `refresh` завершается `failed` с текстом Jira (R-32). + scrum-доска стала канбаном, `refresh` пропускает её и сообщает хабу новый тип (R-36); если доска не отдала спринты по + другой причине (удалена, нет доступа), `refresh` завершается `failed` с номером доски и текстом Jira (R-32). 4. **Особенности `refresh`.** В `result.issues` попадают только запрошенные задачи, соседи по связям — в `result.neighbors`. Если `jql` вернул пустую выдачу, состояние в хаб не отправляется (`pages = 0`). Отсутствующие ключи при `refresh` по `keys` попадают в `result.missing` (не больше 50). diff --git a/openspec/changes/agent-v12-project-boards/.openspec.yaml b/openspec/changes/agent-v12-project-boards/.openspec.yaml index c1bc902..343531d 100644 --- a/openspec/changes/agent-v12-project-boards/.openspec.yaml +++ b/openspec/changes/agent-v12-project-boards/.openspec.yaml @@ -1,2 +1,3 @@ schema: spec-driven created: 2026-10-19 +skip_specs: true diff --git a/openspec/changes/agent-v12-project-boards/design.md b/openspec/changes/agent-v12-project-boards/design.md index b575c52..33e4654 100644 --- a/openspec/changes/agent-v12-project-boards/design.md +++ b/openspec/changes/agent-v12-project-boards/design.md @@ -17,3 +17,4 @@ | R-33 | 2026-10-19 | Запасной `settings.board_id` — только когда ключа `boards` нет (`refresh`) или `board_id` нет / `null` (`ensure_sprint`); `boards: []` — ни одной доски, спринты не читаются | Новый хаб оставляет `board_id` в настройках (решение 14 хаба) и шлёт `boards` всегда; `[]` от него значит «у проектов нет досок», а не «старый хаб» | Если старый хаб когда-нибудь пришлёт `boards: []`, спринты пропадут из зеркала; такого хаба нет | | R-34 | 2026-10-19 | Ключ `boards` в результате `refresh` — только если агент запросил тип хотя бы одной доски; иначе ключа нет | Спека: результат «для досок, чей тип агент запрашивал»; сценарий «Старый хаб» — «результат без `boards`» | Хаб SHALL принимать результат без `boards`; если он ждёт `{}`, — ошибка разбора результата в хабе | | R-35 | 2026-10-19 | Доски сравниваются и выводятся по `str(id)`; повторы в `boards` отбрасываются (запрос типа — один); элемент без `id` пропускается; тип принимается только точным `"scrum"`/`"kanban"`, иное (`"simple"` и т. п.) — `null` со строкой в логе; спринты читаются в порядке `boards`, последнее найденное имя побеждает | Ключи результата в спеке — строки; хаб шлёт без повторов, но повтор не должен стоить второго запроса; сценарий спеки — «один раз» | Доска `"4216"` строкой и `4216` числом считаются одной; другой регистр типа от Jira (`"Scrum"`) дал бы `null` | +| R-36 | 2026-10-19 | По находке I-1 финального ревью: если scrum-доска не отдала спринты в `refresh`, агент перепроверяет её тип; стала `kanban` — доска пропускается, в результате `boards: {"": "kanban"}`, `refresh` `done`; иначе `failed` с текстом «доска : …» (код и `retryable` прежние) | Смена доски на канбан лечится без правки базы; при прочих ошибках владелец видит, какую доску править (R-32 расширил зону поражения на все проекты) | Лишний `GET /board/{id}` при ошибке спринтов; типы досок, определённые в упавшем прогоне, не доходят до хаба — следующий `refresh` спросит снова | diff --git a/src/agent.py b/src/agent.py index 4e704bc..f048097 100644 --- a/src/agent.py +++ b/src/agent.py @@ -1043,7 +1043,9 @@ class Executor: types: Dict[str, Optional[str]] = {} seen = set() for b in p.get("boards") or []: - bid = (b or {}).get("id") + if not isinstance(b, dict): # хаб шлёт [{id, type}]; иное не роняет refresh (M-2) + continue + bid = b.get("id") if bid is None or str(bid) in seen: continue seen.add(str(bid)) @@ -1054,15 +1056,28 @@ class Executor: scrum.append(bid) return scrum, (types or None) - def sprint_names(self, keys: List[str], boards: List[Any]) -> Dict[str, str]: + def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]: + """Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается + (R-36); иначе ошибка Jira с номером доски, refresh failed (R-32, I-1).""" + try: + return self.sprints(board) + except HttpError as e: + if self.board_type(board) == "kanban": + types[str(board)] = "kanban" + return {} + raise HttpError(e.code, "доска %s: %s" % (board, e.text), e.headers) from None + + def sprint_names(self, keys: List[str], boards: List[Any], + types: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, str]: # Источник спринта — Agile по активным и будущим спринтам досок (R-6): id поля спринта в настройках хаба нет, # поэтому у задач закрытых спринтов sprint = None (принятое ограничение). Задача в спринтах двух досок — # побеждает последнее найденное имя. names = {} if not keys: return names + types = {} if types is None else types for board in boards: - for name, sp in self.sprints(board).items(): + for name, sp in self.board_sprints(board, types).items(): for i in range(0, len(keys), 100): chunk = keys[i:i + 100] start = 0 @@ -1107,7 +1122,8 @@ class Executor: for i in range(0, len(absent), 100): neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) everything = raws + neighbors - names = self.sprint_names([r["key"] for r in everything], scrum) + types = dict(types or {}) + names = self.sprint_names([r["key"] for r in everything], scrum, types) issues, links, seen = [], [], set() for r in everything: st = self.to_state(r) @@ -1134,7 +1150,7 @@ class Executor: result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} if missing: result["missing"] = list(dict.fromkeys(missing))[:50] - if types is not None: + if types: result["boards"] = types return self._out("done", result) diff --git a/tests/fake_jira.py b/tests/fake_jira.py index 7e08bc4..24a70a2 100644 --- a/tests/fake_jira.py +++ b/tests/fake_jira.py @@ -361,6 +361,8 @@ class FakeJira: m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p) if m: board = int(m.group(1)) + if board not in self.boards: + 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(",") diff --git a/tests/test_executor_links.py b/tests/test_executor_links.py index 228ad88..9a61723 100644 --- a/tests/test_executor_links.py +++ b/tests/test_executor_links.py @@ -219,6 +219,7 @@ class ExecutorLinksTest(unittest.TestCase): self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2") def test_sprint_unknown_name_names_param_board(self): + self.fake.boards[5120] = "scrum" self.fake.board_sprints[5120] = [{"id": 7, "name": "Спринт K", "state": "active"}] out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2", board_id=5120) self.assertEqual(out["status"], "failed") diff --git a/tests/test_refresh.py b/tests/test_refresh.py index 01843f0..bda5174 100644 --- a/tests/test_refresh.py +++ b/tests/test_refresh.py @@ -302,7 +302,6 @@ class RefreshTest(unittest.TestCase): self.assertTrue(out["retryable"]) self.assertNotIn("peer", out) - JQL = "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" def refresh(self, **params): @@ -374,6 +373,7 @@ class RefreshTest(unittest.TestCase): self.assertEqual(out["status"], "done", out) self.assertEqual(out["result"]["boards"], {"5120": None}) self.assertTrue(any("5120" in l and "HTTP 500" in l for l in logs), logs) + self.assertEqual(len(self.hub.states), 1) # M-4: состояние ушло в хаб def test_board_other_type_is_null(self): self.fake.boards[7000] = "simple" @@ -404,14 +404,29 @@ class RefreshTest(unittest.TestCase): self.refresh(jql=self.JQL, boards=both[::-1]) self.assertEqual(self.hub.states[-1]["issues"][0]["sprint"], "Спринт 2") - def test_scrum_board_sprints_error_fails_refresh(self): # R-32 + def test_scrum_board_turned_kanban_self_heals(self): # I-1, R-36 self.fake.boards[5120] = "kanban" # хаб думает, что scrum + self.fake.add("AURORA-1", sprint="Спринт 2") + out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}, {"id": 5120, "type": "scrum"}]) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2") + + def test_scrum_board_sprints_error_fails_refresh(self): # R-32, I-1: ошибка называет доску self.fake.add("AURORA-1") - out = self.refresh(jql=self.JQL, boards=[{"id": 5120, "type": "scrum"}]) + out = self.refresh(jql=self.JQL, boards=[{"id": 9999, "type": "scrum"}]) # доски нет в Jira self.assertEqual(out["status"], "failed") - self.assertIn("HTTP 400", out["error"]) + self.assertIn("HTTP 404", out["error"]) + self.assertIn("доска 9999", out["error"]) + self.assertFalse(out["retryable"]) self.assertEqual(self.hub.states, []) + def test_non_dict_board_item_skipped(self): # M-2 + self.fake.boards[5120] = "kanban" + out = self.refresh(keys=[], boards=[4216, None, {"id": 5120, "type": None}]) + self.assertEqual(out["status"], "done", out) + self.assertEqual(out["result"]["boards"], {"5120": "kanban"}) + if __name__ == "__main__": unittest.main() -- 2.47.2