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 urllib.request
import uuid import uuid
from datetime import datetime, timezone 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 AGENT_VERSION = 11
EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8) EXIT_LOCKED = 73 # «журнал занят другим процессом»: для run.py это не сбой версии (design 8)
@ -1021,36 +1021,75 @@ class Executor:
out.append(self.comment_state(c)) out.append(self.comment_state(c))
return out return out
def sprint_names(self, keys: List[str]) -> Dict[str, str]: def board_type(self, board: Any) -> Optional[str]:
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет, """Тип доски Jira: scrum или kanban; иное и ошибка Jira — None с записью в лог (refresh продолжается)."""
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение). 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 = {} names = {}
if not keys or not self.s.get("board_id"): if not keys:
return names return names
for name, sp in self.sprints(self.s["board_id"]).items(): for board in boards:
for i in range(0, len(keys), 100): for name, sp in self.sprints(board).items():
chunk = keys[i:i + 100] for i in range(0, len(keys), 100):
start = 0 chunk = keys[i:i + 100]
while True: start = 0
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk), while True:
fields="key", startAt=start, maxResults=50) page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
got = page.get("issues") or [] fields="key", startAt=start, maxResults=50)
for it in got: got = page.get("issues") or []
names[it["key"]] = name for it in got:
start += len(got) names[it["key"]] = name
if not got or start >= int(page.get("total") or 0): start += len(got)
break if not got or start >= int(page.get("total") or 0):
break
return names return names
def do_refresh(self, p: dict, cmd: dict) -> dict: def do_refresh(self, p: dict, cmd: dict) -> dict:
missing: List[str] = [] missing: List[str] = []
errors: List[HttpError] = [] errors: List[HttpError] = []
scrum, types = self.refresh_boards(p) # первым шагом: тип досок нужен хабу и при пустых keys
if p.get("jql"): if p.get("jql"):
raws = self.search_all(p["jql"]) raws = self.search_all(p["jql"])
else: else:
keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k)) keys = list(dict.fromkeys(k for k in (p.get("keys") or []) if k))
if not keys: 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 = [] raws = []
for i in range(0, len(keys), 100): for i in range(0, len(keys), 100):
raws += self.search_tolerant(keys[i:i + 100], missing, errors) raws += self.search_tolerant(keys[i:i + 100], missing, errors)
@ -1068,7 +1107,7 @@ class Executor:
for i in range(0, len(absent), 100): for i in range(0, len(absent), 100):
neighbors += self.search_tolerant(absent[i:i + 100], missing, errors) neighbors += self.search_tolerant(absent[i:i + 100], missing, errors)
everything = raws + neighbors 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() issues, links, seen = [], [], set()
for r in everything: for r in everything:
st = self.to_state(r) st = self.to_state(r)
@ -1095,6 +1134,8 @@ class Executor:
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages} result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
if missing: if missing:
result["missing"] = list(dict.fromkeys(missing))[:50] result["missing"] = list(dict.fromkeys(missing))[:50]
if types is not None:
result["boards"] = types
return self._out("done", result) return self._out("done", result)

View File

@ -266,7 +266,7 @@ class RefreshTest(unittest.TestCase):
def test_sprint_names_paginated(self): def test_sprint_names_paginated(self):
for i in range(1, 121): for i in range(1, 121):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2") 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) self.assertEqual(len(names), 100)
pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"] pages = [r for r in self.fake.requests if r[1] == "/rest/agile/1.0/sprint/2/issue"]
self.assertEqual(len(pages), 2) self.assertEqual(len(pages), 2)
@ -303,5 +303,115 @@ class RefreshTest(unittest.TestCase):
self.assertNotIn("peer", out) 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__": if __name__ == "__main__":
unittest.main() unittest.main()