From 9dd3dd7d9d2122ac1d7717fac9a9accbf5d98da5 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:16:52 +0400 Subject: [PATCH] =?UTF-8?q?feat(agent):=20refresh=20=D0=BF=D0=BE=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B0=D0=BC=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=E2=80=94=20=D1=82=D0=B8=D0=BF=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D0=BA=D0=B8,=20=D1=81=D0=BF=D1=80=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=D0=BE=20=D0=B2=D1=81=D0=B5=D1=85=20scrum-?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BE=D0=BA,=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=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()