Агент: колонки, kanban-бэклог и все спринты досок в первой пачке refresh (#49)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 18:47:22 +04:00
parent 9de78842b1
commit 0ea1438b1e
3 changed files with 234 additions and 14 deletions

View File

@ -679,6 +679,8 @@ class Executor:
self.sleep = time.sleep
self._symmetric = None
self._sprints = {} # str(доска) → имя → спринт; на одну команду
self._all_sprints = {} # str(доска) → все спринты доски (любых состояний); на одну команду
self._status_names = None # id статуса → имя; кэш на экземпляр, пока жив Executor
self._field_list = None # GET /field: список полей на одну команду
self._jira_fields = None # ранг, спринт, оценка → id поля; см. jira_fields
self._jira_fields_ok = False # поля прочитаны без ошибки: кэш живёт, пока жив Executor
@ -686,6 +688,7 @@ class Executor:
def run(self, cmd: dict) -> dict:
self.jira.reset_counters()
self._sprints = {}
self._all_sprints = {}
self._field_list = None
if not self._jira_fields_ok: # неудачный поиск полей — повтор в следующей команде (R-67)
self._jira_fields = None
@ -1191,7 +1194,7 @@ class Executor:
i = it.get("id")
if isinstance(i, int) and not isinstance(i, bool):
sid = i
elif isinstance(i, str) and i.isdigit():
elif isinstance(i, str) and i.isascii() and i.isdigit():
sid = int(i)
elif isinstance(it, str):
m = re.search(r"[\[,]id=(\d+)", it)
@ -1313,13 +1316,14 @@ class Executor:
log("refresh: доска %s — тип «%s» не scrum/kanban" % (board, t))
return None
def refresh_boards(self, p: dict) -> Tuple[List[Any], Optional[Dict[str, Optional[str]]]]:
"""(scrum-доски для имён спринтов, типы запрошенных досок для результата или None).
Без boards в команде — хаб до проектов: доска из настроек, как v11."""
def refresh_boards(self, p: dict) -> Tuple[List[Any], List[Tuple[Any, str]], Optional[Dict[str, Optional[str]]]]:
"""(scrum-доски для имён спринтов, доски с типом scrum/kanban для данных досок, типы запрошенных досок для
результата или None). Без boards в команде — хаб до проектов: доска из настроек, как v11."""
if "boards" not in p:
board = self.s.get("board_id")
return ([board] if board else []), None
return ([board] if board else []), [], None
scrum: List[Any] = []
typed: List[Tuple[Any, str]] = []
types: Dict[str, Optional[str]] = {}
seen = set()
for b in p.get("boards") or []:
@ -1334,7 +1338,89 @@ class Executor:
t = types[str(bid)] = self.board_type(bid)
if t == "scrum":
scrum.append(bid)
return scrum, (types or None)
if t in ("scrum", "kanban"):
typed.append((bid, t))
return scrum, typed, (types or None)
# --- данные досок в первой пачке refresh (v15) -------------------------------------------------------------
def status_names(self) -> Dict[str, str]:
"""id статуса → имя (GET /status); кэш на экземпляр, ошибка Jira наружу и не кэшируется."""
if self._status_names is None:
self._status_names = {str(x["id"]): x["name"] for x in self.jira.get(API + "/status") if x.get("id") and x.get("name")}
return self._status_names
def board_columns(self, board: Any) -> Optional[List[dict]]:
"""Колонки доски: [{name, statuses: [имя]}]; неизвестный id статуса пропускается; ошибка Jira — лог и None."""
try:
names = self.status_names()
cfg = self.jira.get(AGILE + "/board/%s/configuration" % board)
except HttpError as e:
log("доска %s — колонки не прочитаны (HTTP %d)" % (board, e.code))
return None
out = []
for col in (cfg.get("columnConfig") or {}).get("columns") or []:
out.append({"name": col.get("name"),
"statuses": [names[str(st.get("id"))] for st in col.get("statuses") or [] if str(st.get("id")) in names]})
return out
def kanban_backlog(self, board: Any) -> Optional[bool]:
"""Включён ли бэклог канбан-доски: ответ — True, 400 — False, иная ошибка — None с записью в лог."""
try:
self.jira.get(AGILE + "/board/%s/backlog" % board, maxResults=0)
except HttpError as e:
if e.code == 400:
return False
log("refresh: доска %s — бэклог не прочитан (HTTP %d)" % (board, e.code))
return None
return True
def all_sprints(self, board: Any) -> List[dict]:
"""Все спринты доски любых состояний (GET /board/{id}/sprint без state, по 50); кэш на команду.
Заодно заполняет self._sprints именами активных и будущих, чтобы sprints() не спрашивал Jira повторно."""
key = str(board)
if key not in self._all_sprints:
out, start = [], 0
while True:
page = self.jira.get(AGILE + "/board/%s/sprint" % board, startAt=start, maxResults=50)
vals = page.get("values") or []
out += vals
if page.get("isLast", True) or not vals:
break
start += len(vals)
self._all_sprints[key] = out
self._sprints[key] = {sp["name"]: sp for sp in out
if sp.get("name") and str(sp.get("state")).lower() in ("active", "future")}
return self._all_sprints[key]
@staticmethod
def sprint_state(sp: dict) -> Optional[dict]:
"""Спринт для хаба; без id/name или с состоянием вне active/future/closed — None (R-72)."""
state = sp.get("state")
state = state.lower() if isinstance(state, str) else None
if sp.get("id") is None or not sp.get("name") or state not in ("active", "future", "closed"):
return None
return {"id": sp["id"], "name": sp["name"], "state": state, "start_date": sp.get("startDate"),
"end_date": sp.get("endDate"), "complete_date": sp.get("completeDate"), "goal": sp.get("goal")}
def board_state(self, board: Any, kind: str) -> Optional[dict]:
"""Элемент boards первой пачки; ошибка чтения спринтов — доска не отправляется (R-65), колонок — columns null."""
columns = self.board_columns(board)
backlog = self.kanban_backlog(board) if kind == "kanban" else None
sprints: List[dict] = []
if kind == "scrum":
try:
raw = self.all_sprints(board)
except HttpError as e:
log("refresh: доска %s — спринты не прочитаны (HTTP %d), данные доски не отправлены" % (board, e.code))
return None
for sp in raw:
st = self.sprint_state(sp)
if st is None:
log("refresh: доска %s — спринт %r пропущен: нет id/name или состояние не active/future/closed"
% (board, sp.get("id")))
else:
sprints.append(st)
return {"id": board, "type": kind, "columns": columns, "kanban_backlog": backlog, "sprints": sprints}
def board_sprints(self, board: Any, types: Dict[str, Optional[str]]) -> Dict[str, dict]:
"""Спринты scrum-доски для refresh. Доска не отдала спринты: стала канбаном — тип в types, доска пропускается
@ -1372,16 +1458,26 @@ class Executor:
break
return names
def send_empty_batch(self, cmd: dict, boards: List[dict]) -> None:
self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": [], "links": [],
"neighbor_keys": [], "page": 1, "last_page": True, "boards": boards})
def do_refresh(self, p: dict, cmd: dict) -> dict:
missing: List[str] = []
errors: List[HttpError] = []
scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
scrum, typed, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
boards = None
if "boards" in p:
boards = [b for b in (self.board_state(bid, kind) for bid, kind in typed) if b is not None]
if p.get("jql"):
raws = self.search_all(p["jql"])
else:
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
if not keys:
empty: Dict[str, Any] = {"issues": 0, "neighbors": 0, "pages": 0}
if boards is not None: # данные досок уходят пустой пачкой (R-66)
self.send_empty_batch(cmd, boards)
empty["pages"] = 1
if types is not None:
empty["boards"] = types
return self._out("done", empty)
@ -1428,10 +1524,16 @@ class Executor:
chunk = issues[i:i + 100]
chunk_keys = {x["key"] for x in chunk}
pages += 1
self._hub(self.hub.state, {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk,
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
"page": pages, "last_page": i + 100 >= len(issues)})
state = {"command_id": cmd.get("id"), "agent_version": AGENT_VERSION, "issues": chunk,
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
"page": pages, "last_page": i + 100 >= len(issues)}
if pages == 1 and boards is not None:
state["boards"] = boards
self._hub(self.hub.state, state)
if not pages and boards is not None: # выборка пуста, а доски прочитаны (R-66)
self.send_empty_batch(cmd, boards)
pages = 1
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
history = self.refresh_history(p, cmd, everything)
if history is not None:

View File

@ -24,6 +24,9 @@ LINK_TYPES = [{"name": "ASUPR_Блокер_Начала", "inward": "Начал
SPRINT_FIELD = "customfield_10104"
RANK_FIELD = "customfield_10105"
POINTS_FIELD = "customfield_10106"
STATUSES = {"10000": "Ожидание разработки", "10001": "Ожидает анализа", "10002": "Анализ", "10003": "Разработка",
"10004": "Готово к тестированию", "10005": "Тестирование", "10006": "Готово", "10007": "Снято"}
COLUMNS = [("Бэклог", ["10000", "10001"]), ("В работе", ["10002", "10003", "10004", "10005"]), ("Готово", ["10006", "10007"])]
CUSTOM = ("customfield_10801", "customfield_10802", "customfield_15302", "customfield_15304", RANK_FIELD, POINTS_FIELD)
@ -74,6 +77,9 @@ class FakeJira:
self.sprints = [{"id": 1, "name": "Спринт 1", "state": "active"}, {"id": 2, "name": "Спринт 2", "state": "future"}]
self.boards = {4216: "scrum"} # доска → тип; GET /rest/agile/1.0/board/{id}
self.board_sprints = {} # доска → спринты; доски нет — общий self.sprints
self.board_columns = {} # доска → список как COLUMNS (нет доски — COLUMNS)
self.backlog_codes = {} # доска → код ответа GET /board/{id}/backlog (по умолчанию 200)
self.fail_paths = {} # путь → [коды]: каждый запрос по пути снимает первый и отвечает им
self.sprint_field_format = "object" # элемент поля спринтов: словарь или строка greenhopper
self.sprint_members = {} # id спринта → ключи задач (иначе — по имени спринта задачи)
self.seq, self.link_seq, self.comment_seq = 1000, 100, 10
@ -286,6 +292,9 @@ class FakeJira:
if self.fail_next:
code, headers = self.fail_next.pop(0)
return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, headers)
if self.fail_paths.get(url.path):
code = self.fail_paths[url.path].pop(0)
return self._send(h, code, {"errorMessages": ["имитация ошибки"]}, {})
try:
code, payload = self.route(method, url.path, q, body)
except KeyError as e:
@ -472,6 +481,22 @@ class FakeJira:
if board not in self.boards:
return 404, {"errorMessages": ["Board does not exist"]}
return 200, {"id": board, "name": "Доска %d" % board, "type": self.boards[board]}
if p == "/rest/api/2/status":
return 200, [{"id": i, "name": n} for i, n in STATUSES.items()]
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/configuration$", p)
if m:
board = int(m.group(1))
if board not in self.boards:
return 404, {"errorMessages": ["Board does not exist"]}
cols = self.board_columns.get(board, COLUMNS)
return 200, {"id": board, "columnConfig": {"columns": [
{"name": n, "statuses": [{"id": i} for i in ids]} for n, ids in cols]}}
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/backlog$", p)
if m:
code = self.backlog_codes.get(int(m.group(1)), 200)
if code != 200:
return code, {"errorMessages": ["имитация ошибки"]}
return 200, {"startAt": 0, "maxResults": 0, "total": 0, "issues": []}
m = re.match(r"^/rest/agile/1\.0/board/(\d+)/sprint$", p)
if m:
board = int(m.group(1))
@ -479,8 +504,8 @@ class FakeJira:
return 404, {"errorMessages": ["Board does not exist"]}
if self.boards.get(board) == "kanban":
return 400, {"errorMessages": ["The board does not support sprints"]}
states = (q.get("state") or "active,future").split(",")
vals = [s for s in self.board_sprints.get(board, self.sprints) if s["state"] in states]
states = q["state"].split(",") if q.get("state") else None # без state — спринты всех состояний
vals = [s for s in self.board_sprints.get(board, self.sprints) if states is None or s["state"] in states]
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
return 200, {"values": vals[start:start + maxr], "isLast": start + maxr >= len(vals)}
m = re.match(r"^/rest/agile/1\.0/sprint/(\d+)/issue$", p)

View File

@ -5,6 +5,11 @@ from fake_hub import FakeHub
from fake_jira import FakeJira
COLS = [{"name": "Бэклог", "statuses": ["Ожидание разработки", "Ожидает анализа"]},
{"name": "В работе", "statuses": ["Анализ", "Разработка", "Готово к тестированию", "Тестирование"]},
{"name": "Готово", "statuses": ["Готово", "Снято"]}]
class RefreshTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
@ -430,6 +435,8 @@ class RefreshTest(unittest.TestCase):
self.assertNotIn("boards", out["result"])
self.assertIn("/rest/agile/1.0/board/4216/sprint", [r[1] for r in self.fake.requests])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
self.assertNotIn("boards", self.hub.states[0])
self.assertFalse(any(r[1].endswith("/configuration") for r in self.fake.requests))
def test_known_boards_no_type_requests(self):
self.fake.add("AURORA-1", sprint="Спринт 2")
@ -471,7 +478,11 @@ class RefreshTest(unittest.TestCase):
self.fake.boards[5120] = "kanban"
out = self.refresh(keys=[], boards=[{"id": 5120, "type": None}])
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0, "boards": {"5120": "kanban"}})
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 1, "boards": {"5120": "kanban"}})
self.assertEqual(self.hub.states, [{"command_id": 5, "agent_version": agent.AGENT_VERSION, "issues": [], "links": [],
"neighbor_keys": [], "page": 1, "last_page": True,
"boards": [{"id": 5120, "type": "kanban", "columns": COLS,
"kanban_backlog": True, "sprints": []}]}])
def test_last_found_sprint_wins_across_boards(self):
self.fake.boards[5120] = "scrum"
@ -507,6 +518,88 @@ class RefreshTest(unittest.TestCase):
self.assertEqual(out["status"], "done", out)
self.assertEqual(out["result"]["boards"], {"5120": "kanban"})
def test_board_with_closed_sprints(self): # сценарий «Доска с закрытыми спринтами»
sp = lambda i, s, **kw: dict({"id": i, "name": "S%d" % i, "state": s}, **kw)
self.fake.board_sprints[4216] = [
sp(1, "closed", startDate="2026-09-01T09:00:00.000+03:00", endDate="2026-09-14T18:00:00.000+03:00",
completeDate="2026-09-15T10:00:00.000+03:00", goal="цель 1"),
sp(2, "closed"), sp(3, "active"), sp(4, "future")]
for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="S3")
self.fake.issues["AURORA-1"]["sprint_ids"] = [2, 3]
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(out["status"], "done", out)
b = self.hub.states[0]["boards"]
self.assertEqual([(x["id"], x["type"], x["columns"], x["kanban_backlog"]) for x in b], [(4216, "scrum", COLS, None)])
self.assertEqual([s["id"] for s in b[0]["sprints"]], [1, 2, 3, 4])
self.assertEqual(b[0]["sprints"][0], {"id": 1, "name": "S1", "state": "closed",
"start_date": "2026-09-01T09:00:00.000+03:00", "end_date": "2026-09-14T18:00:00.000+03:00",
"complete_date": "2026-09-15T10:00:00.000+03:00", "goal": "цель 1"})
self.assertNotIn("boards", self.hub.states[1])
by = {i["key"]: i for i in self.hub.states[0]["issues"]}
self.assertEqual((by["AURORA-1"]["sprint_ids"], by["AURORA-1"]["sprint"]), ([2, 3], "S3"))
sprint_reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
self.assertEqual(len(sprint_reads), 1) # имена спринтов — из того же списка
self.assertNotIn("state", sprint_reads[0])
def test_all_sprints_paged(self):
self.fake.board_sprints[4216] = [{"id": i, "name": "S%d" % i, "state": "closed"} for i in range(1, 121)]
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
reads = [q for (m, path), q in zip(self.fake.requests, self.fake.queries) if path == "/rest/agile/1.0/board/4216/sprint"]
self.assertEqual(len(reads), 3)
self.assertEqual(len(self.hub.states[0]["boards"][0]["sprints"]), 120)
def test_kanban_backlog_flag(self):
self.fake.boards[5120] = "kanban"
for code, want in ((200, True), (400, False), (500, None)):
self.fake.backlog_codes[5120] = code
self.refresh(keys=[], boards=[{"id": 5120, "type": "kanban"}])
b = self.hub.states[-1]["boards"][0]
self.assertEqual((b["kanban_backlog"], b["sprints"], b["columns"]), (want, [], COLS))
self.assertNotIn("/rest/agile/1.0/board/5120/sprint", [r[1] for r in self.fake.requests])
def test_board_config_error_columns_null(self):
self.fake.add("AURORA-1")
self.fake.fail_paths["/rest/agile/1.0/board/4216/configuration"] = [403]
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(out["status"], "done", out)
b = self.hub.states[0]["boards"][0]
self.assertIsNone(b["columns"])
self.assertEqual([s["id"] for s in b["sprints"]], [1, 2])
self.assertTrue(any("4216" in l and "HTTP 403" in l for l in logs), logs)
def test_board_sprints_error_board_omitted(self): # R-65
self.fake.add("AURORA-1", sprint="Спринт 2")
self.fake.fail_paths["/rest/agile/1.0/board/4216/sprint"] = [403] # второй запрос (имена спринтов) — успешен
out, logs = self.logged(lambda: self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual(out["status"], "done", out)
self.assertEqual(self.hub.states[0]["boards"], [])
self.assertEqual(self.hub.states[0]["issues"][0]["sprint"], "Спринт 2")
def test_board_bad_sprint_dropped(self): # R-72
self.fake.board_sprints[4216] = [{"id": 1, "name": "S1", "state": "CLOSED"}, {"id": 2, "name": "S2", "state": "weird"},
{"id": 3, "state": "active"}]
out, logs = self.logged(lambda: self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}]))
self.assertEqual([(s["id"], s["state"]) for s in self.hub.states[0]["boards"][0]["sprints"]], [(1, "closed")])
self.assertEqual(len([l for l in logs if "пропущен" in l]), 2, logs)
def test_unknown_status_id_skipped_and_status_cached(self):
self.fake.board_columns[4216] = [("Одна", ["10003", "99999"])]
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.refresh(keys=[], boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(self.hub.states[0]["boards"][0]["columns"], [{"name": "Одна", "statuses": ["Разработка"]}])
self.assertEqual(self.fake.requests.count(("GET", "/rest/api/2/status")), 1)
def test_empty_jql_with_boards_sends_empty_batch(self): # R-66
out = self.refresh(jql=self.JQL, boards=[{"id": 4216, "type": "scrum"}])
self.assertEqual(out["result"]["pages"], 1)
st = self.hub.states[0]
self.assertEqual((st["issues"], st["links"], st["neighbor_keys"], st["page"], st["last_page"]), ([], [], [], 1, True))
self.assertEqual(st["boards"][0]["id"], 4216)
def test_sprint_ids_non_ascii_digits(self):
self.assertEqual(self.ex.sprint_ids_of("AURORA-1", [{"id": "²"}, {"id": "٣"}, {"id": "7"}]), [7])
class JiraFieldsTest(unittest.TestCase):
def setUp(self):