feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 20:16:52 +04:00
parent fc87f54cf9
commit 9dd3dd7d9d
2 changed files with 172 additions and 21 deletions

View File

@ -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)

View File

@ -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()