feat(agent): чтение состояния — поиск, соседи по связям, спринты, страницы в хаб

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
Andrey Epifancev 2026-10-11 23:50:42 +04:00
parent fbc9ca9048
commit 29fefb6114
3 changed files with 299 additions and 0 deletions

View File

@ -608,6 +608,112 @@ class Executor:
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
return self._out("done", {"sprint": want, "from": cur})
# --- refresh ---------------------------------------------------------------
STATE_FIELDS = ["summary", "description", "status", "priority", "assignee", "reporter", "duedate", "labels",
"issuetype", "created", "updated", "issuelinks"]
def state_fields(self) -> str:
extra = [self.fid(x) for x in ("planned_start", "planned_end") if self.has_field(x)]
ep = (self.s.get("epic_link") or {})
if ep.get("mode", "field") == "field" and ep.get("field_id"):
extra.append(ep["field_id"])
return ",".join(self.STATE_FIELDS + extra)
def search_all(self, jql: str) -> List[dict]:
issues, start = [], 0
while True:
page = self.jira.get(API + "/search", jql=jql, fields=self.state_fields(), startAt=start, maxResults=100)
got = page.get("issues") or []
issues += got
start += len(got)
if not got or start >= int(page.get("total") or 0):
return issues
def to_state(self, raw: dict) -> dict:
f = raw.get("fields") or {}
def name(obj):
return (obj or {}).get("name")
ep = self.s.get("epic_link") or {}
epic = f.get(ep["field_id"]) if ep.get("mode", "field") == "field" and ep.get("field_id") else None
return {"key": raw["key"], "id": raw.get("id"), "issue_type": name(f.get("issuetype")), "summary": f.get("summary"),
"description": f.get("description"), "status": name(f.get("status")), "priority": name(f.get("priority")),
"assignee": name(f.get("assignee")), "reporter": name(f.get("reporter")), "duedate": _date(f.get("duedate")),
"planned_start": _date(f.get(self.fid("planned_start"))) if self.has_field("planned_start") else None,
"planned_end": _date(f.get(self.fid("planned_end"))) if self.has_field("planned_end") else None,
"epic_key": epic, "labels": list(f.get("labels") or []), "sprint": None,
"created": f.get("created"), "jira_updated": f.get("updated")}
def links_from(self, raw: dict) -> List[dict]:
key, out = raw["key"], []
for l in (raw.get("fields") or {}).get("issuelinks") or []:
t = (l.get("type") or {}).get("name")
if l.get("outwardIssue"):
out.append({"source_key": key, "target_key": l["outwardIssue"]["key"], "link_type": t})
if l.get("inwardIssue"):
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
return out
def sprint_names(self, keys: List[str]) -> Dict[str, str]:
names = {}
if not keys or not self.s.get("board_id"):
return names
for name, sp in self.sprints().items():
for i in range(0, len(keys), 100):
chunk = keys[i:i + 100]
page = self.jira.get(AGILE + "/sprint/%s/issue" % sp["id"], jql="key in (%s)" % ",".join(chunk),
fields="key", maxResults=200)
for it in page.get("issues") or []:
names[it["key"]] = name
return names
def do_refresh(self, p: dict, cmd: dict) -> dict:
if p.get("jql"):
raws = self.search_all(p["jql"])
else:
keys = [k for k in (p.get("keys") or []) if k]
if not keys:
return self._out("done", {"issues": 0, "neighbors": 0, "pages": 0})
raws = []
for i in range(0, len(keys), 100):
raws += self.search_all("key in (%s)" % ",".join(keys[i:i + 100]))
fetched = {r["key"] for r in raws}
known = set(self.hub.known_keys()) if self.hub else set()
linked = set()
for r in raws:
for l in self.links_from(r):
linked.add(l["source_key"])
linked.add(l["target_key"])
missing = sorted(k for k in linked if k not in fetched and k not in known)
neighbors = []
for i in range(0, len(missing), 100):
neighbors += self.search_all("key in (%s)" % ",".join(missing[i:i + 100]))
everything = raws + neighbors
names = self.sprint_names([r["key"] for r in everything])
issues, links, seen = [], [], set()
for r in everything:
st = self.to_state(r)
st["sprint"] = names.get(r["key"])
issues.append(st)
for l in self.links_from(r):
sig = (l["source_key"], l["target_key"], l["link_type"])
if sig not in seen:
seen.add(sig)
links.append(l)
neighbor_keys = [r["key"] for r in neighbors]
pages = 0
for i in range(0, len(issues), 100):
chunk = issues[i:i + 100]
chunk_keys = {x["key"] for x in chunk}
pages += 1
self.hub.state({"command_id": cmd.get("id"), "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)})
return self._out("done", {"issues": len(raws), "neighbors": len(neighbors), "pages": pages})
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

108
tests/fake_hub.py Normal file
View File

@ -0,0 +1,108 @@
"""Фейковый хаб для тестов агента: маршруты /agent/* в памяти."""
import json
import re
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class FakeHub:
token = "agent-token"
def __init__(self, settings):
self.settings = dict(settings)
self.queue = []
self.commands = {}
self.results = {}
self.states = []
self.known = []
self.claims = 0
self.fail_results = 0
self.reject_results = {}
self.in_flight = None
self.lock = threading.Lock()
hub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
hub.handle(self, "GET")
def do_POST(self):
hub.handle(self, "POST")
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
@property
def base_url(self):
return "http://127.0.0.1:%d" % self.server.server_port
def stop(self):
self.server.shutdown()
self.server.server_close()
def enqueue(self, ctype, params, pause=0):
cid = len(self.commands) + 1
self.commands[cid] = {"id": cid, "type": ctype, "params": params, "pause_seconds": pause, "status": "queued"}
self.queue.append(cid)
return cid
def handle(self, h, method):
length = int(h.headers.get("Content-Length") or 0)
body = json.loads(h.rfile.read(length).decode("utf-8")) if length else {}
if h.headers.get("Authorization") != "Bearer " + self.token:
return self._send(h, 401, {"error": "unauthorized"})
with self.lock:
code, payload = self.route(method, h.path.split("?")[0], body)
self._send(h, code, payload)
def _send(self, h, code, payload):
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
h.send_response(code)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(data)))
h.end_headers()
h.wfile.write(data)
def route(self, method, path, body):
if path == "/agent/claim":
self.claims += 1
if self.in_flight and self.commands[self.in_flight]["status"] == "claimed":
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
if not self.queue:
return 200, {"command": None, "settings_version": self.settings["settings_version"]}
cid = self.queue.pop(0)
self.commands[cid]["status"] = "claimed"
self.in_flight = cid
c = self.commands[cid]
return 200, {"command": {"id": cid, "type": c["type"], "params": c["params"], "pause_seconds": c["pause_seconds"]},
"settings_version": self.settings["settings_version"]}
if path == "/agent/settings":
return 200, self.settings
if path == "/agent/known-keys":
return 200, {"keys": list(self.known)}
if path == "/agent/state":
self.states.append(body)
return 200, {"issues": len(body.get("issues") or []), "links": len(body.get("links") or [])}
m = re.match(r"^/agent/commands/(\d+)$", path)
if m:
c = self.commands.get(int(m.group(1)))
return (200, c) if c else (404, {"error": "not_found"})
m = re.match(r"^/agent/commands/(\d+)/result$", path)
if m:
cid = int(m.group(1))
if self.fail_results > 0:
self.fail_results -= 1
return 503, {"error": "busy"}
if cid in self.reject_results:
return self.reject_results[cid], {"error": "rejected"}
if cid not in self.commands:
return 404, {"error": "not_found"}
self.results[cid] = body
self.commands[cid]["status"] = body.get("status")
if self.in_flight == cid:
self.in_flight = None
return 200, self.commands[cid]
return 404, {"error": "not_found", "detail": path}

85
tests/test_refresh.py Normal file
View File

@ -0,0 +1,85 @@
import unittest
from _common import SETTINGS, agent, jira_cfg, no_sleep
from fake_hub import FakeHub
from fake_jira import FakeJira
class RefreshTest(unittest.TestCase):
def setUp(self):
self.fake = FakeJira()
self.hub = FakeHub(SETTINGS)
cfg = jira_cfg(self.fake, hub_url=self.hub.base_url, hub_token=self.hub.token)
self.jira = agent.JiraClient(cfg)
self.jira.sleep = no_sleep
self.ex = agent.Executor(self.jira, dict(SETTINGS), agent.HubClient(cfg))
self.ex.sleep = no_sleep
def tearDown(self):
self.fake.stop()
self.hub.stop()
def test_to_state_and_links(self):
self.fake.add("AURORA-1", assignee="ivanov", customfield_10801="2026-10-20T00:00:00.000+0300",
customfield_15302="AURORA-1350", labels=["AWPCA", "kind-dev"], sprint="Спринт 2")
self.fake.add("AURORA-2")
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-1", "target": "AURORA-2"})
raw = self.fake.issue_json("AURORA-1")
st = self.ex.to_state(raw)
self.assertEqual(st["key"], "AURORA-1")
self.assertEqual(st["assignee"], "ivanov")
self.assertEqual(st["planned_start"], "2026-10-20")
self.assertEqual(st["epic_key"], "AURORA-1350")
self.assertEqual(st["labels"], ["AWPCA", "kind-dev"])
self.assertEqual(st["issue_type"], "Задача")
self.assertEqual(st["jira_updated"], "2026-10-01T10:00:00.000+0300")
self.assertIsNone(st["sprint"]) # спринт заполняет do_refresh
self.assertEqual(self.ex.links_from(raw), [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
self.assertEqual(self.ex.links_from(self.fake.issue_json("AURORA-2")),
[{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "ASUPR_Блокер_Начала"}])
self.assertIn("customfield_15302", self.ex.state_fields())
def test_full_refresh_with_neighbors_and_sprints(self):
for i in range(1, 4):
self.fake.add("AURORA-%d" % i, sprint="Спринт 2" if i < 3 else None)
self.fake.add("OTHER-9", in_base=False)
self.fake.add("AURORA-7", in_base=False)
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "OTHER-9"})
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-7", "target": "AURORA-2"})
self.hub.known = ["AURORA-7"]
out = self.ex.run({"id": 5, "type": "refresh", "params": {"jql": "project = AURORA AND labels = AWPCA"}})
self.assertEqual(out["status"], "done")
self.assertEqual(out["result"], {"issues": 3, "neighbors": 1, "pages": 1})
state = self.hub.states[0]
self.assertEqual(state["command_id"], 5)
self.assertEqual(sorted(i["key"] for i in state["issues"]), ["AURORA-1", "AURORA-2", "AURORA-3", "OTHER-9"])
self.assertEqual(state["neighbor_keys"], ["OTHER-9"])
self.assertTrue(state["last_page"])
sprints = {i["key"]: i["sprint"] for i in state["issues"]}
self.assertEqual(sprints["AURORA-1"], "Спринт 2")
self.assertIsNone(sprints["AURORA-3"])
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([r[1] for r in self.fake.requests if r[1] == "/rest/api/2/search"].count("/rest/api/2/search"), 2)
def test_refresh_by_keys_pages(self):
for i in range(1, 121):
self.fake.add("AURORA-%d" % i)
keys = ["AURORA-%d" % i for i in range(1, 121)]
out = self.ex.run({"id": 6, "type": "refresh", "params": {"keys": keys}})
self.assertEqual(out["result"], {"issues": 120, "neighbors": 0, "pages": 2})
self.assertEqual(len(self.hub.states), 2)
self.assertEqual(self.hub.states[0]["page"], 1)
self.assertFalse(self.hub.states[0]["last_page"])
self.assertTrue(self.hub.states[1]["last_page"])
self.assertEqual(len(self.hub.states[0]["issues"]) + len(self.hub.states[1]["issues"]), 120)
def test_refresh_empty_keys(self):
out = self.ex.run({"id": 7, "type": "refresh", "params": {"keys": []}})
self.assertEqual(out["result"], {"issues": 0, "neighbors": 0, "pages": 0})
self.assertEqual(self.fake.requests, [])
self.assertEqual(self.hub.states, [])
if __name__ == "__main__":
unittest.main()