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:
parent
fbc9ca9048
commit
29fefb6114
106
src/agent.py
106
src/agent.py
@ -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
108
tests/fake_hub.py
Normal 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
85
tests/test_refresh.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user