feat(agent): refresh по доскам проектов — тип доски, спринты со всех scrum-досок, результат boards (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fc87f54cf9
commit
9dd3dd7d9d
81
src/agent.py
81
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)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user