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
3957e85656
commit
a071cc12f8
109
src/agent.py
109
src/agent.py
@ -482,6 +482,115 @@ class Executor:
|
||||
c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
|
||||
return self._out("done", {"comment_id": c.get("id"), "created": True})
|
||||
|
||||
# --- ensure_status ---------------------------------------------------------
|
||||
def do_ensure_status(self, p: dict, cmd: dict) -> dict:
|
||||
key, target = p["key"], p["target"]
|
||||
path = list(target) if isinstance(target, list) else [target]
|
||||
final = path[-1]
|
||||
cur = self.jira.get(API + "/issue/%s" % key, fields="status")["fields"]["status"]["name"]
|
||||
start = cur
|
||||
if cur == final:
|
||||
return self._out("done", {"from": start, "to": final, "steps": 0})
|
||||
if cur in path:
|
||||
path = path[path.index(cur) + 1:]
|
||||
steps = 0
|
||||
for step in path:
|
||||
if cur == step:
|
||||
continue
|
||||
trs = self.jira.get(API + "/issue/%s/transitions" % key).get("transitions", [])
|
||||
match = [t for t in trs if (t.get("to") or {}).get("name") == step or t.get("name") == step]
|
||||
if not match:
|
||||
avail = ", ".join("%s→%s" % (t.get("name"), (t.get("to") or {}).get("name")) for t in trs)
|
||||
return self._out("failed", {"from": start, "reached": cur},
|
||||
"нет перехода из «%s» в «%s»; доступны: %s" % (cur, step, avail or "—"))
|
||||
if steps:
|
||||
self.gap("ensure_status")
|
||||
self.jira.post(API + "/issue/%s/transitions" % key, {"transition": {"id": match[0]["id"]}})
|
||||
cur = step
|
||||
steps += 1
|
||||
return self._out("done", {"from": start, "to": cur, "steps": steps})
|
||||
|
||||
# --- ensure_link -----------------------------------------------------------
|
||||
def symmetric_types(self) -> set:
|
||||
"""Типы, у которых inward == outward («Связанные»): направление не важно."""
|
||||
if self._symmetric is None:
|
||||
try:
|
||||
types = self.jira.get(API + "/issueLinkType").get("issueLinkTypes", [])
|
||||
self._symmetric = {t["name"] for t in types if t.get("inward") == t.get("outward")}
|
||||
except HttpError:
|
||||
self._symmetric = set()
|
||||
return self._symmetric
|
||||
|
||||
def do_ensure_link(self, p: dict, cmd: dict) -> dict:
|
||||
src, dst = p["source"], p["target"]
|
||||
types = self.s.get("link_types") or {}
|
||||
lt = types.get(p["link_type"], p["link_type"])
|
||||
managed = set(types.values()) | {lt}
|
||||
symmetric = self.symmetric_types()
|
||||
raw = self.jira.get(API + "/issue/%s" % src, fields="issuelinks")["fields"].get("issuelinks") or []
|
||||
links = [] # (тип, другая задача, направление, id)
|
||||
for l in raw:
|
||||
t = (l.get("type") or {}).get("name")
|
||||
if l.get("outwardIssue"):
|
||||
links.append((t, l["outwardIssue"]["key"], "out", l.get("id")))
|
||||
if l.get("inwardIssue"):
|
||||
links.append((t, l["inwardIssue"]["key"], "in", l.get("id")))
|
||||
wanted = [x for x in links if x[1] == dst and x[0] == lt and (x[2] == "out" or lt in symmetric)]
|
||||
conflicts = [x for x in links if x[1] == dst and x[0] in managed and x not in wanted]
|
||||
if conflicts and not p.get("replace"):
|
||||
desc = ", ".join("«%s» (%s)" % (t, "обратное направление" if t == lt else "другой тип") for t, _, _, _ in conflicts)
|
||||
return self._out("failed", error="между %s и %s уже есть: %s; нужен replace" % (src, dst, desc))
|
||||
removed = []
|
||||
for t, other, direction, lid in conflicts:
|
||||
if not lid:
|
||||
return self._out("failed", {"removed": removed}, "у связи «%s» с %s нет id — снять нельзя" % (t, other))
|
||||
if removed:
|
||||
self.gap("ensure_link")
|
||||
self.jira.delete(API + "/issueLink/%s" % lid)
|
||||
removed.append(t)
|
||||
if wanted:
|
||||
return self._out("done", {"existed": True, "removed": removed})
|
||||
if removed:
|
||||
self.gap("ensure_link")
|
||||
# inwardIssue — источник (получает outward-описание «Блокирует начало…»), outwardIssue — цель
|
||||
self.jira.post(API + "/issueLink", {"type": {"name": lt}, "inwardIssue": {"key": src}, "outwardIssue": {"key": dst}})
|
||||
return self._out("done", {"created": True, "removed": removed})
|
||||
|
||||
# --- ensure_sprint ---------------------------------------------------------
|
||||
def sprints(self) -> Dict[str, dict]:
|
||||
"""Имя → спринт (active и future) доски board_id; без доски — пусто."""
|
||||
if self._sprints is None:
|
||||
out, start = {}, 0
|
||||
board = self.s.get("board_id")
|
||||
while board:
|
||||
page = self.jira.get(AGILE + "/board/%s/sprint" % board, state="active,future", startAt=start, maxResults=50)
|
||||
for sp in page.get("values", []):
|
||||
out[sp["name"]] = sp
|
||||
if page.get("isLast", True) or not page.get("values"):
|
||||
break
|
||||
start += len(page["values"])
|
||||
self._sprints = out
|
||||
return self._sprints
|
||||
|
||||
def do_ensure_sprint(self, p: dict, cmd: dict) -> dict:
|
||||
key, want = p["key"], p["sprint"]
|
||||
if not self.s.get("board_id"):
|
||||
return self._out("failed", error="board_id не задан в настройках хаба — спринты недоступны")
|
||||
cur = ((self.jira.get(AGILE + "/issue/%s" % key, fields="sprint")["fields"].get("sprint") or {}).get("name"))
|
||||
if want == "backlog":
|
||||
if cur is None:
|
||||
return self._out("done", {"sprint": None, "changed": False})
|
||||
self.jira.post(AGILE + "/backlog/issue", {"issues": [key]})
|
||||
return self._out("done", {"sprint": None, "from": cur})
|
||||
if cur == want:
|
||||
return self._out("done", {"sprint": want, "changed": False})
|
||||
sp = self.sprints().get(want)
|
||||
if not sp:
|
||||
return self._out("failed", error="нет спринта «%s» среди активных и будущих доски %s: %s"
|
||||
% (want, self.s["board_id"], ", ".join(sorted(self.sprints())) or "—"))
|
||||
self.jira.post(AGILE + "/sprint/%s/issue" % sp["id"], {"issues": [key]})
|
||||
return self._out("done", {"sprint": want, "from": cur})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
|
||||
128
tests/test_executor_links.py
Normal file
128
tests/test_executor_links.py
Normal file
@ -0,0 +1,128 @@
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
class ExecutorLinksTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.fake = FakeJira()
|
||||
self.jira = agent.JiraClient(jira_cfg(self.fake))
|
||||
self.jira.sleep = no_sleep
|
||||
self.ex = agent.Executor(self.jira, dict(SETTINGS))
|
||||
self.gaps = []
|
||||
self.ex.sleep = self.gaps.append
|
||||
self.fake.add("AURORA-1")
|
||||
self.fake.add("AURORA-2")
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def run_cmd(self, ctype, **params):
|
||||
return self.ex.run({"id": 1, "type": ctype, "params": params})
|
||||
|
||||
# --- статус ---
|
||||
def test_status_path(self):
|
||||
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"], {"from": "Ожидание разработки", "to": "Готово", "steps": 2})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово")
|
||||
again = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||||
self.assertEqual(again["result"]["steps"], 0)
|
||||
self.assertEqual(again["jira_calls"], 1)
|
||||
|
||||
def test_status_resumes_from_middle(self):
|
||||
self.fake.issues["AURORA-1"]["status"] = "Разработка"
|
||||
out = self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово к тестированию"])
|
||||
self.assertEqual(out["result"]["steps"], 1)
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Готово к тестированию")
|
||||
|
||||
def test_status_single_and_by_transition_name(self):
|
||||
out = self.run_cmd("ensure_status", key="AURORA-1", target="Взять в разработку")
|
||||
self.assertEqual(out["result"]["to"], "Взять в разработку") # имя перехода принято как шаг
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["status"], "Разработка")
|
||||
|
||||
def test_status_no_transition_is_failed(self):
|
||||
out = self.run_cmd("ensure_status", key="AURORA-1", target="Тестирование")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("нет перехода из «Ожидание разработки» в «Тестирование»", out["error"])
|
||||
self.assertIn("Взять в разработку→Разработка", out["error"])
|
||||
self.assertEqual(out["result"], {"from": "Ожидание разработки", "reached": "Ожидание разработки"})
|
||||
|
||||
def test_status_gap_between_steps(self):
|
||||
self.ex.s["pacing"] = {"ensure_status": 7}
|
||||
self.run_cmd("ensure_status", key="AURORA-1", target=["Разработка", "Готово"])
|
||||
self.assertEqual(self.gaps, [7])
|
||||
|
||||
# --- связи ---
|
||||
def test_link_create_and_idempotent(self):
|
||||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||||
self.assertEqual(out["result"], {"created": True, "removed": []})
|
||||
link = self.fake.links[0]
|
||||
self.assertEqual((link["source"], link["target"], link["type"]), ("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала"))
|
||||
again = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||||
self.assertEqual(again["result"], {"existed": True, "removed": []})
|
||||
self.assertEqual(len(self.fake.links), 1)
|
||||
|
||||
def test_link_symmetric_any_direction(self):
|
||||
self.fake.links.append({"id": 1, "type": "ASUPR_Связанные", "source": "AURORA-2", "target": "AURORA-1"})
|
||||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
|
||||
self.assertEqual(out["result"], {"existed": True, "removed": []})
|
||||
|
||||
def test_link_conflict_without_replace(self):
|
||||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
|
||||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("обратное направление", out["error"])
|
||||
self.assertEqual(len(self.fake.links), 1)
|
||||
|
||||
def test_link_replace_reverse_and_other_type(self):
|
||||
self.ex.s["pacing"] = {"ensure_link": 3}
|
||||
self.fake.links.append({"id": 1, "type": "ASUPR_Блокер_Начала", "source": "AURORA-2", "target": "AURORA-1"})
|
||||
self.fake.links.append({"id": 2, "type": "ASUPR_Связанные", "source": "AURORA-1", "target": "AURORA-2"})
|
||||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="блокирует начало", replace=True)
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(sorted(out["result"]["removed"]), ["ASUPR_Блокер_Начала", "ASUPR_Связанные"])
|
||||
self.assertTrue(out["result"]["created"])
|
||||
self.assertEqual([(l["source"], l["target"], l["type"]) for l in self.fake.links],
|
||||
[("AURORA-1", "AURORA-2", "ASUPR_Блокер_Начала")])
|
||||
self.assertEqual(self.gaps, [3, 3]) # между двумя DELETE и перед POST
|
||||
self.assertEqual([r[0] for r in self.fake.writes()], ["DELETE", "DELETE", "POST"])
|
||||
|
||||
def test_link_unmanaged_type_ignored(self):
|
||||
self.fake.links.append({"id": 1, "type": "Duplicate", "source": "AURORA-1", "target": "AURORA-2"})
|
||||
out = self.run_cmd("ensure_link", source="AURORA-1", target="AURORA-2", link_type="связанные")
|
||||
self.assertEqual(out["result"], {"created": True, "removed": []})
|
||||
self.assertEqual(len(self.fake.links), 2)
|
||||
|
||||
# --- спринты ---
|
||||
def test_sprint_move_and_idempotent(self):
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||||
self.assertEqual(out["result"], {"sprint": "Спринт 2", "from": None})
|
||||
self.assertEqual(self.fake.issues["AURORA-1"]["sprint"], "Спринт 2")
|
||||
again = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||||
self.assertEqual(again["result"], {"sprint": "Спринт 2", "changed": False})
|
||||
self.assertEqual(again["jira_calls"], 1)
|
||||
|
||||
def test_sprint_backlog(self):
|
||||
self.fake.issues["AURORA-1"]["sprint"] = "Спринт 1"
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")
|
||||
self.assertEqual(out["result"], {"sprint": None, "from": "Спринт 1"})
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["sprint"])
|
||||
self.assertEqual(self.run_cmd("ensure_sprint", key="AURORA-1", sprint="backlog")["result"], {"sprint": None, "changed": False})
|
||||
|
||||
def test_sprint_unknown_name(self):
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 9")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("Спринт 9", out["error"])
|
||||
self.assertIn("Спринт 2", out["error"])
|
||||
|
||||
def test_sprint_without_board(self):
|
||||
self.ex.s["board_id"] = None
|
||||
out = self.run_cmd("ensure_sprint", key="AURORA-1", sprint="Спринт 2")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("board_id", out["error"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user