From ced48cf9db07a5d8c01351c025701af45ed55a2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:31:59 +0400 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D1=82=D0=BE=D0=B2=20ref?= =?UTF-8?q?resh=20=D0=BD=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=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()