feat(agent): исполнитель — ensure_issue, ensure_fields, ensure_comment
Поиск дубля в ensure_issue до создания (автор = currentUser(), точная тема). ensure_fields пишет только отличающиеся поля, описание сравнивается точно, даты — по первым 10 символам. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Typ5sXWNMFirr2va2uL8o
This commit is contained in:
parent
5de91d6452
commit
07bee853b4
147
src/agent.py
147
src/agent.py
@ -336,6 +336,153 @@ def _parse_at(value) -> float:
|
||||
return dt.timestamp()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
|
||||
# ---------------------------------------------------------------------------
|
||||
def _date(value) -> Optional[str]:
|
||||
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
|
||||
return (value or "")[:10] or None
|
||||
|
||||
|
||||
class Executor:
|
||||
def __init__(self, jira: JiraClient, settings: dict, hub: Optional[HubClient] = None):
|
||||
self.jira, self.s, self.hub = jira, settings, hub
|
||||
self.sleep = time.sleep
|
||||
self._symmetric = None
|
||||
self._sprints = None
|
||||
|
||||
def run(self, cmd: dict) -> dict:
|
||||
self.jira.reset_counters()
|
||||
self._sprints = None
|
||||
handler = getattr(self, "do_" + str(cmd.get("type")), None)
|
||||
if handler is None:
|
||||
return self._out("failed", error="неизвестный тип команды %s" % cmd.get("type"))
|
||||
try:
|
||||
return handler(cmd.get("params") or {}, cmd)
|
||||
except HttpError as e:
|
||||
return self._out("failed", error="HTTP %d %s" % (e.code, e.text[:1500]))
|
||||
except ConnectionError as e:
|
||||
return self._out("failed", error=str(e))
|
||||
|
||||
def _out(self, status, result=None, error=None) -> dict:
|
||||
return {"status": status, "result": result, "error": error,
|
||||
"jira_calls": self.jira.calls, "rate_limited": self.jira.rate_limited}
|
||||
|
||||
def gap(self, cmd_type: str) -> None:
|
||||
"""Зазор между записями внутри одной команды — по политике хаба для этого типа."""
|
||||
seconds = int((self.s.get("pacing") or {}).get(cmd_type, 0) or 0)
|
||||
if seconds > 0:
|
||||
log(" пауза %d с" % seconds)
|
||||
self.sleep(seconds)
|
||||
|
||||
def fid(self, logical: str) -> str:
|
||||
return (self.s.get("field_ids") or {}).get(logical, logical)
|
||||
|
||||
def has_field(self, logical: str) -> bool:
|
||||
return logical in ("summary", "description", "priority", "duedate", "labels") or bool((self.s.get("field_ids") or {}).get(logical))
|
||||
|
||||
def priority_value(self, name: str) -> dict:
|
||||
mapped = (self.s.get("priority_map") or {}).get(name, name)
|
||||
return {self.s.get("priority_value_key") or "name": mapped}
|
||||
|
||||
def issue_type_ref(self, kind: str) -> dict:
|
||||
it = (self.s.get("issue_types") or {}).get(kind) or {}
|
||||
return {"id": str(it["id"])} if it.get("id") else {"name": it.get("name")}
|
||||
|
||||
def epic_fields(self, epic_key) -> dict:
|
||||
ep = self.s.get("epic_link") or {}
|
||||
if epic_key and ep.get("mode", "field") == "field" and ep.get("field_id"):
|
||||
return {ep["field_id"]: epic_key}
|
||||
return {}
|
||||
|
||||
# --- ensure_issue ----------------------------------------------------------
|
||||
def do_ensure_issue(self, p: dict, cmd: dict) -> dict:
|
||||
jql = "project = %s AND reporter = currentUser() AND created >= -7d" % self.s["project_key"]
|
||||
found = self.jira.get(API + "/search", jql=jql, fields="summary", maxResults=200)
|
||||
for it in found.get("issues", []):
|
||||
if (it.get("fields", {}).get("summary") or "") == p["summary"]:
|
||||
return self._out("done", {"key": it["key"], "existed": True})
|
||||
fields = {"project": {"key": self.s["project_key"]}, "issuetype": self.issue_type_ref(p["issue_type"]),
|
||||
"summary": p["summary"]}
|
||||
if p.get("description") is not None:
|
||||
fields[self.fid("description")] = p["description"]
|
||||
if p.get("priority"):
|
||||
fields[self.fid("priority")] = self.priority_value(p["priority"])
|
||||
if p.get("duedate"):
|
||||
fields[self.fid("duedate")] = p["duedate"]
|
||||
for logical in ("planned_start", "planned_end"):
|
||||
if p.get(logical) and self.has_field(logical):
|
||||
fields[self.fid(logical)] = p[logical]
|
||||
if p.get("labels") is not None:
|
||||
fields[self.fid("labels")] = list(p["labels"])
|
||||
if p.get("assignee"):
|
||||
fields["assignee"] = {"name": p["assignee"]}
|
||||
fields.update(self.epic_fields(p.get("epic_key")))
|
||||
if p["issue_type"] == "epic" and self.s.get("epic_name_field_id"):
|
||||
fields[self.s["epic_name_field_id"]] = p["summary"]
|
||||
created = self.jira.post(API + "/issue", {"fields": fields})
|
||||
return self._out("done", {"key": created["key"], "created": True})
|
||||
|
||||
# --- ensure_fields ---------------------------------------------------------
|
||||
def do_ensure_fields(self, p: dict, cmd: dict) -> dict:
|
||||
key = p["key"]
|
||||
cur = self.jira.get(API + "/issue/%s" % key)["fields"]
|
||||
fields, changed = {}, []
|
||||
if "summary" in p and (cur.get("summary") or "") != (p["summary"] or ""):
|
||||
fields["summary"] = p["summary"]
|
||||
changed.append("summary")
|
||||
if "description" in p and (cur.get(self.fid("description")) or "") != (p["description"] or ""):
|
||||
fields[self.fid("description")] = p["description"]
|
||||
changed.append("description")
|
||||
if "priority" in p:
|
||||
want = self.priority_value(p["priority"])
|
||||
vkey = list(want)[0]
|
||||
if ((cur.get(self.fid("priority")) or {}).get(vkey)) != want[vkey]:
|
||||
fields[self.fid("priority")] = want
|
||||
changed.append("priority")
|
||||
for logical in ("duedate", "planned_start", "planned_end"):
|
||||
if logical in p and self.has_field(logical) and _date(cur.get(self.fid(logical))) != _date(p[logical]):
|
||||
fields[self.fid(logical)] = p[logical]
|
||||
changed.append(logical)
|
||||
if "epic_key" in p:
|
||||
ep = self.s.get("epic_link") or {}
|
||||
if ep.get("field_id") and (cur.get(ep["field_id"]) or None) != (p["epic_key"] or None):
|
||||
fields[ep["field_id"]] = p["epic_key"]
|
||||
changed.append("epic_key")
|
||||
if "assignee" in p and ((cur.get("assignee") or {}).get("name") or None) != (p["assignee"] or None):
|
||||
fields["assignee"] = {"name": p["assignee"]} if p["assignee"] else None
|
||||
changed.append("assignee")
|
||||
if "labels_add" in p or "labels_remove" in p:
|
||||
labels = list(cur.get(self.fid("labels")) or [])
|
||||
remove = set(p.get("labels_remove") or [])
|
||||
new = [l for l in labels if l not in remove] + [l for l in (p.get("labels_add") or []) if l not in labels]
|
||||
if new != labels:
|
||||
fields[self.fid("labels")] = new
|
||||
changed.append("labels")
|
||||
if not fields:
|
||||
return self._out("done", {"changed": []})
|
||||
self.jira.put(API + "/issue/%s" % key, {"fields": fields})
|
||||
return self._out("done", {"changed": changed})
|
||||
|
||||
# --- ensure_comment --------------------------------------------------------
|
||||
def do_ensure_comment(self, p: dict, cmd: dict) -> dict:
|
||||
key, marker, body = p["key"], p["marker"], p["body"]
|
||||
if marker not in body[:80]:
|
||||
body = "*%s.* %s" % (marker, body)
|
||||
existing = None
|
||||
for c in self.jira.get(API + "/issue/%s/comment" % key).get("comments", []):
|
||||
if marker in (c.get("body") or "")[:80]:
|
||||
existing = c
|
||||
break
|
||||
if existing and (existing.get("body") or "") == body:
|
||||
return self._out("done", {"comment_id": existing["id"], "changed": False})
|
||||
if existing:
|
||||
self.jira.put(API + "/issue/%s/comment/%s" % (key, existing["id"]), {"body": body})
|
||||
return self._out("done", {"comment_id": existing["id"], "changed": True})
|
||||
c = self.jira.post(API + "/issue/%s/comment" % key, {"body": body})
|
||||
return self._out("done", {"comment_id": c.get("id"), "created": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
109
tests/test_executor_fields.py
Normal file
109
tests/test_executor_fields.py
Normal file
@ -0,0 +1,109 @@
|
||||
import unittest
|
||||
|
||||
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||
from fake_jira import FakeJira
|
||||
|
||||
|
||||
class ExecutorFieldsTest(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.ex.sleep = no_sleep
|
||||
|
||||
def tearDown(self):
|
||||
self.fake.stop()
|
||||
|
||||
def run_cmd(self, ctype, **params):
|
||||
return self.ex.run({"id": 1, "type": ctype, "params": params})
|
||||
|
||||
def test_ensure_issue_creates_then_finds(self):
|
||||
out = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача", description="h3. Контекст",
|
||||
priority="Высокий", duedate="2026-10-23", planned_start="2026-10-20", labels=["AWPCA", "kind-dev"],
|
||||
epic_key="AURORA-1350", assignee="ivanov")
|
||||
self.assertEqual(out["status"], "done")
|
||||
key = out["result"]["key"]
|
||||
self.assertTrue(out["result"]["created"])
|
||||
f = self.fake.issues[key]
|
||||
self.assertEqual(f["priority"], "Высокий")
|
||||
self.assertEqual(f["customfield_10801"], "2026-10-20")
|
||||
self.assertEqual(f["customfield_15302"], "AURORA-1350")
|
||||
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
|
||||
self.assertEqual(out["jira_calls"], 2)
|
||||
again = self.run_cmd("ensure_issue", temp_key="new:1", issue_type="task", summary="Новая задача")
|
||||
self.assertEqual(again["status"], "done")
|
||||
self.assertEqual(again["result"], {"key": key, "existed": True})
|
||||
self.assertEqual(len(self.fake.issues), 1)
|
||||
self.assertEqual(again["jira_calls"], 1)
|
||||
|
||||
def test_ensure_issue_epic_sets_epic_name(self):
|
||||
out = self.run_cmd("ensure_issue", temp_key="new:2", issue_type="epic", summary="Приёмка")
|
||||
f = self.fake.issues[out["result"]["key"]]
|
||||
self.assertEqual(f["issuetype"], "Фича")
|
||||
self.assertEqual(f["customfield_15304"], "Приёмка")
|
||||
|
||||
def test_ensure_issue_priority_mapped(self):
|
||||
out = self.run_cmd("ensure_issue", temp_key="new:3", issue_type="task", summary="x", priority="Наивысший")
|
||||
self.assertEqual(self.fake.issues[out["result"]["key"]]["priority"], "Критичный")
|
||||
|
||||
def test_ensure_fields_writes_only_diff(self):
|
||||
self.fake.add("AURORA-1", duedate="2026-10-20", labels=["AWPCA", "fact-open"], priority="Средний")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", priority="Средний",
|
||||
labels_add=["kind-dev", "AWPCA"], labels_remove=["fact-open"], assignee="ivanov",
|
||||
planned_end="2026-10-23", epic_key="AURORA-1350")
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertEqual(out["result"]["changed"], ["duedate", "planned_end", "epic_key", "assignee", "labels"])
|
||||
f = self.fake.issues["AURORA-1"]
|
||||
self.assertEqual(f["labels"], ["AWPCA", "kind-dev"])
|
||||
self.assertEqual(f["assignee"], "ivanov")
|
||||
self.assertEqual(f["customfield_10802"], "2026-10-23")
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
again = self.run_cmd("ensure_fields", key="AURORA-1", duedate="2026-10-23", labels_add=["kind-dev"], assignee="ivanov")
|
||||
self.assertEqual(again["result"], {"changed": []})
|
||||
self.assertEqual(len(self.fake.writes()), 1)
|
||||
|
||||
def test_fields_description_exact_compare(self):
|
||||
text = "h3. Контекст\n* пункт *жирный*\n\nh3. Что сделать"
|
||||
self.fake.add("AURORA-1", description=text)
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text)
|
||||
self.assertEqual(out["result"]["changed"], [])
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", description=text + "\n")
|
||||
self.assertEqual(out["result"]["changed"], ["description"])
|
||||
|
||||
def test_fields_date_with_time_from_jira(self):
|
||||
self.fake.add("AURORA-1", customfield_10802="2026-10-23T00:00:00.000+0300")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", planned_end="2026-10-23")
|
||||
self.assertEqual(out["result"]["changed"], [])
|
||||
|
||||
def test_fields_clear_assignee_and_epic(self):
|
||||
self.fake.add("AURORA-1", assignee="ivanov", customfield_15302="AURORA-1350")
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-1", assignee=None, epic_key=None)
|
||||
self.assertEqual(out["result"]["changed"], ["epic_key", "assignee"])
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["assignee"])
|
||||
self.assertIsNone(self.fake.issues["AURORA-1"]["customfield_15302"])
|
||||
|
||||
def test_fields_missing_issue_is_failed(self):
|
||||
out = self.run_cmd("ensure_fields", key="AURORA-404", duedate="2026-10-23")
|
||||
self.assertEqual(out["status"], "failed")
|
||||
self.assertIn("HTTP 404", out["error"])
|
||||
|
||||
def test_ensure_comment_upsert(self):
|
||||
self.fake.add("AURORA-1")
|
||||
out = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
|
||||
self.assertEqual(out["status"], "done")
|
||||
self.assertTrue(out["result"]["created"])
|
||||
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: список")
|
||||
same = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: список")
|
||||
self.assertEqual(same["result"]["changed"], False)
|
||||
upd = self.run_cmd("ensure_comment", key="AURORA-1", marker="Актуализация 10.10.2026", body="Сделано: всё")
|
||||
self.assertEqual(upd["result"]["changed"], True)
|
||||
self.assertEqual(len(self.fake.comments["AURORA-1"]), 1)
|
||||
self.assertEqual(self.fake.comments["AURORA-1"][0]["body"], "*Актуализация 10.10.2026.* Сделано: всё")
|
||||
|
||||
def test_unknown_type(self):
|
||||
self.assertEqual(self.run_cmd("ensure_magic", key="x")["status"], "failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user