fix(agent): refresh — бисекция при 400 на key in, missing, пагинация спринтов, правки фейков и тесты
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
1b3364be13
commit
c64d36b748
48
src/agent.py
48
src/agent.py
@ -614,7 +614,7 @@ class Executor:
|
|||||||
|
|
||||||
def state_fields(self) -> str:
|
def state_fields(self) -> str:
|
||||||
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
|
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
|
||||||
ep = (self.s.get("epic_link") or {})
|
ep = (self.s.get("epic_link") or {}) # режим связи с эпиком — только field (mode=link не поддержан)
|
||||||
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
if ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||||||
extra.append(ep["field_id"])
|
extra.append(ep["field_id"])
|
||||||
return ",".join(self.STATE_FIELDS + extra)
|
return ",".join(self.STATE_FIELDS + extra)
|
||||||
@ -629,6 +629,22 @@ class Executor:
|
|||||||
if not got or start >= int(page.get("total") or 0):
|
if not got or start >= int(page.get("total") or 0):
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
def search_tolerant(self, keys: List[str], missing: List[str]) -> List[dict]:
|
||||||
|
"""key in (...): Jira Server отвечает 400 на несуществующий/недоступный ключ. Чанк делим пополам до
|
||||||
|
одиночного ключа; одиночный с 400 — «не найден» (в missing), не ошибка. Прочие HttpError пробрасываются."""
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
return self.search_all("key in (%s)" % ",".join(keys))
|
||||||
|
except HttpError as e:
|
||||||
|
if e.code != 400:
|
||||||
|
raise
|
||||||
|
if len(keys) == 1:
|
||||||
|
missing.append(keys[0])
|
||||||
|
return []
|
||||||
|
mid = len(keys) // 2
|
||||||
|
return self.search_tolerant(keys[:mid], missing) + self.search_tolerant(keys[mid:], missing)
|
||||||
|
|
||||||
def to_state(self, raw: dict) -> dict:
|
def to_state(self, raw: dict) -> dict:
|
||||||
f = raw.get("fields") or {}
|
f = raw.get("fields") or {}
|
||||||
|
|
||||||
@ -656,19 +672,28 @@ class Executor:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
|
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
|
||||||
|
# Источник спринта — Agile по активным и будущим спринтам доски (R-6): id поля спринта в настройках хаба нет,
|
||||||
|
# поэтому у задач закрытых спринтов sprint = None (принятое ограничение).
|
||||||
names = {}
|
names = {}
|
||||||
if not keys or not self.s.get("board_id"):
|
if not keys or not self.s.get("board_id"):
|
||||||
return names
|
return names
|
||||||
for name, sp in self.sprints().items():
|
for name, sp in self.sprints().items():
|
||||||
for i in range(0, len(keys), 100):
|
for i in range(0, len(keys), 100):
|
||||||
chunk = keys[i:i + 100]
|
chunk = keys[i:i + 100]
|
||||||
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
|
start = 0
|
||||||
fields="key", maxResults=200)
|
while True:
|
||||||
for it in page.get("issues") or []:
|
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
|
||||||
names[it["key"]] = name
|
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
|
return names
|
||||||
|
|
||||||
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
def do_refresh(self, p: dict, cmd: dict) -> dict:
|
||||||
|
missing: List[str] = []
|
||||||
if p.get("jql"):
|
if p.get("jql"):
|
||||||
raws = self.search_all(p["jql"])
|
raws = self.search_all(p["jql"])
|
||||||
else:
|
else:
|
||||||
@ -677,7 +702,7 @@ class Executor:
|
|||||||
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
|
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
|
||||||
raws = []
|
raws = []
|
||||||
for i in range(0, len(keys), 100):
|
for i in range(0, len(keys), 100):
|
||||||
raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100]))
|
raws += self.search_tolerant(keys[i:i + 100], missing)
|
||||||
fetched = {r["key"] for r in raws}
|
fetched = {r["key"] for r in raws}
|
||||||
known = set(self.hub.known_keys()) if self.hub else set()
|
known = set(self.hub.known_keys()) if self.hub else set()
|
||||||
linked = set()
|
linked = set()
|
||||||
@ -685,10 +710,10 @@ class Executor:
|
|||||||
for l in self.links_from(r):
|
for l in self.links_from(r):
|
||||||
linked.add(l["source_key"])
|
linked.add(l["source_key"])
|
||||||
linked.add(l["target_key"])
|
linked.add(l["target_key"])
|
||||||
missing = sorted(k for k in linked if k not in fetched and k not in known)
|
absent = sorted(k for k in linked if k not in fetched and k not in known)
|
||||||
neighbors = []
|
neighbors = []
|
||||||
for i in range(0, len(missing), 100):
|
for i in range(0, len(absent), 100):
|
||||||
neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100]))
|
neighbors += self.search_tolerant(absent[i:i + 100], missing)
|
||||||
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])
|
||||||
issues, links, seen = [], [], set()
|
issues, links, seen = [], [], set()
|
||||||
@ -711,7 +736,10 @@ class Executor:
|
|||||||
"links": [l for l in links if l["source_key"] in chunk_keys or l["target_key"] in chunk_keys],
|
"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],
|
"neighbor_keys": [k for k in neighbor_keys if k in chunk_keys],
|
||||||
"page": pages, "last_page": i + 100 >= len(issues)})
|
"page": pages, "last_page": i + 100 >= len(issues)})
|
||||||
return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages})
|
result = {"issues": len(raws), "neighbors": len(neighbors), "pages": pages}
|
||||||
|
if missing:
|
||||||
|
result["missing"] = sorted(missing)[:50]
|
||||||
|
return self._out("done", result)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -100,6 +100,8 @@ class FakeHub:
|
|||||||
return self.reject_results[cid], {"error": "rejected"}
|
return self.reject_results[cid], {"error": "rejected"}
|
||||||
if cid not in self.commands:
|
if cid not in self.commands:
|
||||||
return 404, {"error": "not_found"}
|
return 404, {"error": "not_found"}
|
||||||
|
if self.commands[cid]["status"] in ("done", "failed", "cancelled"):
|
||||||
|
return 200, self.commands[cid] # терминальный статус не перезаписывается (как живой хаб)
|
||||||
self.results[cid] = body
|
self.results[cid] = body
|
||||||
self.commands[cid]["status"] = body.get("status")
|
self.commands[cid]["status"] = body.get("status")
|
||||||
if self.in_flight == cid:
|
if self.in_flight == cid:
|
||||||
|
|||||||
@ -141,6 +141,9 @@ class FakeJira:
|
|||||||
if p == "/rest/api/2/myself":
|
if p == "/rest/api/2/myself":
|
||||||
return 200, {"name": "me", "displayName": "Me"}
|
return 200, {"name": "me", "displayName": "Me"}
|
||||||
if p == "/rest/api/2/search":
|
if p == "/rest/api/2/search":
|
||||||
|
m = re.search(r"key in \(([^)]*)\)", q.get("jql") or "")
|
||||||
|
if m and any(k.strip() not in self.issues for k in m.group(1).split(",")): # как Jira Server
|
||||||
|
return 400, {"errorMessages": ["Задача не существует или у вас нет прав на её просмотр."]}
|
||||||
keys = self.search_keys(q.get("jql"))
|
keys = self.search_keys(q.get("jql"))
|
||||||
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||||||
page = keys[start:start + maxr]
|
page = keys[start:start + maxr]
|
||||||
@ -220,7 +223,9 @@ class FakeJira:
|
|||||||
sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))
|
sp = next(s for s in self.sprints if s["id"] == int(m.group(1)))
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]]
|
keys = [k for k in self.search_keys(q.get("jql") or "key in ()") if self.issues[k]["sprint"] == sp["name"]]
|
||||||
return 200, {"issues": [{"key": k} for k in keys]}
|
start, maxr = int(q.get("startAt", 0)), int(q.get("maxResults", 50))
|
||||||
|
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
|
||||||
|
"issues": [{"key": k} for k in keys[start:start + maxr]]}
|
||||||
for k in body["issues"]:
|
for k in body["issues"]:
|
||||||
self.issues[k]["sprint"] = sp["name"]
|
self.issues[k]["sprint"] = sp["name"]
|
||||||
return 204, None
|
return 204, None
|
||||||
|
|||||||
@ -60,7 +60,8 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertIsNone(sprints["AURORA-3"])
|
self.assertIsNone(sprints["AURORA-3"])
|
||||||
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
|
links = {(l["source_key"], l["target_key"]) for l in state["links"]}
|
||||||
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
|
self.assertEqual(links, {("AURORA-1", "OTHER-9"), ("AURORA-7", "AURORA-2")})
|
||||||
self.assertEqual([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2)
|
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||||||
|
self.assertEqual(len(searches), 2) # базовая выборка + дочитывание соседей
|
||||||
|
|
||||||
def test_refresh_by_keys_pages(self):
|
def test_refresh_by_keys_pages(self):
|
||||||
for i in range(1, 121):
|
for i in range(1, 121):
|
||||||
@ -80,6 +81,74 @@ class RefreshTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.fake.requests, [])
|
self.assertEqual(self.fake.requests, [])
|
||||||
self.assertEqual(self.hub.states, [])
|
self.assertEqual(self.hub.states, [])
|
||||||
|
|
||||||
|
def test_refresh_empty_jql_result(self):
|
||||||
|
out = self.ex.run({"id": 8, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
|
||||||
|
self.assertEqual(self.hub.states, [])
|
||||||
|
|
||||||
|
def test_keys_with_deleted_key(self):
|
||||||
|
for i in (1, 2, 3, 4):
|
||||||
|
self.fake.add("AURORA-%d" % i)
|
||||||
|
out = self.ex.run({"id": 9, "type": "refresh",
|
||||||
|
"params": {"keys": ["AURORA-1", "AURORA-2", "AURORA-99", "AURORA-3", "AURORA-98"]}})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertEqual(out["result"], {"issues": 3, "neighbors": 0, "pages": 1, "missing": ["AURORA-98", "AURORA-99"]})
|
||||||
|
self.assertEqual(sorted(i["key"] for i in self.hub.states[0]["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3"])
|
||||||
|
|
||||||
|
def test_deleted_neighbor(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
self.fake.add("AURORA-2", in_base=False)
|
||||||
|
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "GONE-1"})
|
||||||
|
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||||||
|
out = self.ex.run({"id": 10, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||||||
|
self.assertEqual(out["status"], "done")
|
||||||
|
self.assertEqual(out["result"], {"issues": 1, "neighbors": 1, "pages": 1, "missing": ["GONE-1"]})
|
||||||
|
self.assertEqual(self.hub.states[0]["neighbor_keys"], ["AURORA-2"])
|
||||||
|
|
||||||
|
def test_non_400_error_fails(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
|
||||||
|
def boom(jql):
|
||||||
|
raise agent.HttpError(500, "oops")
|
||||||
|
self.ex.search_all = boom
|
||||||
|
out = self.ex.run({"id": 11, "type": "refresh", "params": {"keys": ["AURORA-1"]}})
|
||||||
|
self.assertEqual(out["status"], "failed")
|
||||||
|
self.assertIn("500", out["error"])
|
||||||
|
|
||||||
|
def test_link_visible_from_both_ends_deduped(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
self.fake.add("AURORA-2")
|
||||||
|
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||||||
|
self.ex.run({"id": 12, "type": "refresh", "params": {"keys": ["AURORA-1", "AURORA-2"]}})
|
||||||
|
self.assertEqual(len(self.hub.states[0]["links"]), 1)
|
||||||
|
|
||||||
|
def test_many_neighbors_several_requests(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
for i in range(1, 151):
|
||||||
|
self.fake.add("NB-%d" % i, in_base=False)
|
||||||
|
self.fake.links.append({"id": i, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "NB-%d" % i})
|
||||||
|
out = self.ex.run({"id": 13, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
|
||||||
|
self.assertEqual(out["result"], {"issues": 1, "neighbors": 150, "pages": 2})
|
||||||
|
searches = [r for r in self.fake.requests if r[1] == "/rest/api/2/search"]
|
||||||
|
self.assertEqual(len(searches), 1 + 2) # база + два чанка соседей
|
||||||
|
|
||||||
|
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)])
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_fake_hub_terminal_result_not_overwritten(self):
|
||||||
|
cid = self.hub.enqueue("refresh", {"keys": []})
|
||||||
|
hub = agent.HubClient(jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token))
|
||||||
|
hub.result(cid, {"status": "done", "result": {"a": 1}})
|
||||||
|
hub.result(cid, {"status": "failed", "error": "x"})
|
||||||
|
self.assertEqual(self.hub.commands[cid]["status"], "done")
|
||||||
|
self.assertEqual(self.hub.results[cid]["status"], "done")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user