Разбор журнала изменений Jira и фейк журнала (#39)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
285cc1c484
commit
de2d5b5252
51
src/agent.py
51
src/agent.py
@ -526,6 +526,27 @@ def _parse_at(value) -> float:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
|
# Исполнитель: команда — желаемый результат; прочитать → сравнить → записать разницу
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
def _text(value: Any) -> Optional[str]:
|
||||||
|
return None if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _jira_time(value: Any) -> Optional[datetime]:
|
||||||
|
"""Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → aware datetime; без
|
||||||
|
смещения — UTC; иначе None. В 3.9 fromisoformat не разбирает ни `Z`, ни `+0300`."""
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
s = value[:-1] + "+0000" if value.endswith("Z") else value
|
||||||
|
if len(s) > 6 and s[-6] in "+-" and s[-3] == ":": # ±HH:MM → ±HHMM
|
||||||
|
s = s[:-3] + s[-2:]
|
||||||
|
for fmt in ("%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(s, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _date(value) -> Optional[str]:
|
def _date(value) -> Optional[str]:
|
||||||
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
|
"""Даты Jira (duedate — YYYY-MM-DD, кастомные — с временем) сравниваем по дню."""
|
||||||
return (value or "")[:10] or None
|
return (value or "")[:10] or None
|
||||||
@ -983,6 +1004,36 @@ class Executor:
|
|||||||
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
|
out.append({"source_key": l["inwardIssue"]["key"], "target_key": key, "link_type": t})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
def changelog_items(self, key: str, histories: List[dict], since: Optional[datetime],
|
||||||
|
field_id_of: Callable[[str], Optional[str]]) -> List[dict]:
|
||||||
|
"""Записи журнала задачи → элементы для хаба (по одному на поле записи). Записи без id или времени и элементы
|
||||||
|
без поля отбрасываются с записью в лог (хаб отверг бы всю пачку); since — только записи не раньше него.
|
||||||
|
field_id: fieldId элемента, у custom без него — по имени поля (field_id_of), у прочих — само поле."""
|
||||||
|
out: List[dict] = []
|
||||||
|
for h in histories:
|
||||||
|
hid, created = h.get("id"), h.get("created")
|
||||||
|
if hid in (None, "") or not created:
|
||||||
|
log("журнал %s: запись без id или времени отброшена" % key)
|
||||||
|
continue
|
||||||
|
at = _jira_time(created)
|
||||||
|
if since is not None and (at is None or at < since):
|
||||||
|
continue
|
||||||
|
a = h.get("author") or {}
|
||||||
|
author = a.get("displayName") or a.get("name") or None
|
||||||
|
for seq, it in enumerate(h.get("items") or []):
|
||||||
|
name = it.get("field")
|
||||||
|
if not name:
|
||||||
|
log("журнал %s: элемент без поля в записи %s отброшен" % (key, hid))
|
||||||
|
continue
|
||||||
|
fid = it.get("fieldId")
|
||||||
|
if not fid:
|
||||||
|
fid = field_id_of(name) if it.get("fieldtype") == "custom" else name
|
||||||
|
out.append({"history_id": str(hid), "seq": seq, "key": key, "created": created, "author": author,
|
||||||
|
"field_id": fid, "field_name": name, "from": _text(it.get("from")),
|
||||||
|
"from_string": _text(it.get("fromString")), "to": _text(it.get("to")),
|
||||||
|
"to_string": _text(it.get("toString"))})
|
||||||
|
return out
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def comment_state(c: dict) -> dict:
|
def comment_state(c: dict) -> dict:
|
||||||
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""
|
"""Комментарий для хаба: автор — отображаемое имя, иначе логин; текст и время — как отдала Jira."""
|
||||||
|
|||||||
@ -87,6 +87,14 @@ class FakeJira:
|
|||||||
self.bad_attachment_response = False # ответить на загрузку пустым списком
|
self.bad_attachment_response = False # ответить на загрузку пустым списком
|
||||||
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
self.last_headers = {} # заголовки текущего запроса (имена в нижнем регистре)
|
||||||
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
self.search_comment_cap = None # сколько комментариев задачи отдаёт search (None — все), как обрезка Jira
|
||||||
|
self.changelogs = {} # ключ → записи журнала Jira [{id, author, created, items}]
|
||||||
|
self.history_seq = 50000
|
||||||
|
self.changelog_cap = None # сколько записей журнала отдаёт search и GET /issue (None — все); total — полное
|
||||||
|
self.issue_changelog_cap = None # то же только для GET /issue/{key}?expand=changelog (None — как changelog_cap)
|
||||||
|
self.jql_results = {} # JQL → ключи (проверяется в search_keys первым)
|
||||||
|
self.fields = [{"id": "customfield_10201", "name": "Плановое окончание", "custom": True},
|
||||||
|
{"id": "customfield_10104", "name": "Sprint", "custom": True},
|
||||||
|
{"id": "duedate", "name": "Срок исполнения", "custom": False}] # GET /rest/api/2/field
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
fake = self
|
fake = self
|
||||||
|
|
||||||
@ -133,6 +141,19 @@ class FakeJira:
|
|||||||
self.issues[key] = base
|
self.issues[key] = base
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
def add_history(self, key, created, items, hid=None, name="petrov", display="Петров Пётр"):
|
||||||
|
"""Запись журнала задачи; возвращает её id."""
|
||||||
|
self.history_seq += 1
|
||||||
|
hid = hid or str(self.history_seq)
|
||||||
|
self.changelogs.setdefault(key, []).append({"id": hid, "author": {"name": name, "displayName": display},
|
||||||
|
"created": created, "items": items})
|
||||||
|
return hid
|
||||||
|
|
||||||
|
def changelog_json(self, key, cap):
|
||||||
|
lst = self.changelogs.get(key, [])
|
||||||
|
shown = lst if cap is None else lst[:cap]
|
||||||
|
return {"startAt": 0, "maxResults": len(shown) if cap is None else cap, "total": len(lst), "histories": shown}
|
||||||
|
|
||||||
def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
|
def add_comment(self, key, body, name="ivanov", display="Иванов Иван", created="2026-10-14T12:00:00.000+0300",
|
||||||
updated=None):
|
updated=None):
|
||||||
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
|
author = {k: v for k, v in (("name", name), ("displayName", display)) if v is not None} if name or display else None
|
||||||
@ -186,6 +207,8 @@ class FakeJira:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
def search_keys(self, jql):
|
def search_keys(self, jql):
|
||||||
|
if jql in self.jql_results:
|
||||||
|
return [k for k in self.jql_results[jql] if k in self.issues]
|
||||||
m = re.search(r"key in \(([^)]*)\)", jql or "")
|
m = re.search(r"key in \(([^)]*)\)", jql or "")
|
||||||
if m:
|
if m:
|
||||||
keys = [k.strip() for k in m.group(1).split(",")]
|
keys = [k.strip() for k in m.group(1).split(",")]
|
||||||
@ -261,8 +284,13 @@ class FakeJira:
|
|||||||
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]
|
||||||
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
|
fields = (q.get("fields") or "").split(",") if q.get("fields") else []
|
||||||
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys),
|
issues = [self.issue_json(k, fields) for k in page]
|
||||||
"issues": [self.issue_json(k, fields) for k in page]}
|
if q.get("expand") == "changelog":
|
||||||
|
for it in issues:
|
||||||
|
it["changelog"] = self.changelog_json(it["key"], self.changelog_cap)
|
||||||
|
return 200, {"startAt": start, "maxResults": maxr, "total": len(keys), "issues": issues}
|
||||||
|
if p == "/rest/api/2/field":
|
||||||
|
return 200, self.fields
|
||||||
if p == "/rest/api/2/issue" and method == "POST":
|
if p == "/rest/api/2/issue" and method == "POST":
|
||||||
self.seq += 1
|
self.seq += 1
|
||||||
key = "%s-%d" % (self.project, self.seq)
|
key = "%s-%d" % (self.project, self.seq)
|
||||||
@ -290,7 +318,11 @@ class FakeJira:
|
|||||||
if m:
|
if m:
|
||||||
key = m.group(1)
|
key = m.group(1)
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
return 200, self.issue_json(key)
|
out = self.issue_json(key)
|
||||||
|
if q.get("expand") == "changelog":
|
||||||
|
cap = self.issue_changelog_cap if self.issue_changelog_cap is not None else self.changelog_cap
|
||||||
|
out["changelog"] = self.changelog_json(key, cap)
|
||||||
|
return 200, out
|
||||||
if method == "PUT":
|
if method == "PUT":
|
||||||
f = self.issues[key]
|
f = self.issues[key]
|
||||||
for k, v in body.get("fields", {}).items():
|
for k, v in body.get("fields", {}).items():
|
||||||
|
|||||||
111
tests/test_history.py
Normal file
111
tests/test_history.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""Журнал изменений Jira в refresh (агент v13, spec agent «Журнал изменений»)."""
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from _common import SETTINGS, agent, jira_cfg, no_sleep
|
||||||
|
from fake_hub import FakeHub
|
||||||
|
from fake_jira import FakeJira
|
||||||
|
|
||||||
|
|
||||||
|
def _st(field, frm, to, fieldtype="jira", **kw):
|
||||||
|
return dict({"field": field, "fieldtype": fieldtype, "from": frm, "fromString": frm, "to": to, "toString": to}, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(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
|
||||||
|
self.logs = []
|
||||||
|
self._log = agent.log
|
||||||
|
agent.log = self.logs.append
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
agent.log = self._log
|
||||||
|
self.fake.stop()
|
||||||
|
self.hub.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class ChangelogItemsTest(Base):
|
||||||
|
def test_jira_time_forms(self):
|
||||||
|
want = datetime(2026, 10, 22, 10, 5, 11, tzinfo=timezone.utc)
|
||||||
|
for raw in ("2026-10-22T13:05:11.000+0300", "2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11Z",
|
||||||
|
"2026-10-22T10:05:11.000Z", "2026-10-22T05:05:11.000-0500"):
|
||||||
|
self.assertEqual(agent._jira_time(raw), want, raw)
|
||||||
|
for raw in ("мусор", "", None, 42):
|
||||||
|
self.assertIsNone(agent._jira_time(raw))
|
||||||
|
|
||||||
|
def test_changelog_items_since_and_shape(self):
|
||||||
|
histories = [
|
||||||
|
{"id": "1", "author": {"name": "petrov", "displayName": "Петров Пётр"}, "created": "2026-10-21T10:00:00.000+0300",
|
||||||
|
"items": [_st("status", "Открыта", "В работе")]},
|
||||||
|
{"id": 2, "author": {"name": "ivanov"}, "created": "2026-10-22T12:10:00.000+0300",
|
||||||
|
"items": [dict(_st("status", "В работе", "Готово"), fieldId="status", **{"from": "3", "to": "10001"}),
|
||||||
|
_st("resolution", None, "Готово")]}]
|
||||||
|
items = self.ex.changelog_items("X", histories, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
|
||||||
|
self.assertEqual([(i["history_id"], i["seq"]) for i in items], [("2", 0), ("2", 1)])
|
||||||
|
self.assertEqual(items[0], {"history_id": "2", "seq": 0, "key": "X", "created": "2026-10-22T12:10:00.000+0300",
|
||||||
|
"author": "ivanov", "field_id": "status", "field_name": "status", "from": "3",
|
||||||
|
"from_string": "В работе", "to": "10001", "to_string": "Готово"})
|
||||||
|
self.assertEqual(items[1]["field_id"], "resolution")
|
||||||
|
self.assertIsNone(items[1]["from"])
|
||||||
|
|
||||||
|
def test_changelog_items_since_boundary_included(self):
|
||||||
|
h = [{"id": "1", "author": None, "created": "2026-10-22T11:45:00.000+0300", "items": [_st("status", "a", "b")]}]
|
||||||
|
items = self.ex.changelog_items("X", h, agent._jira_time("2026-10-22T08:45:00Z"), lambda n: None)
|
||||||
|
self.assertEqual(len(items), 1)
|
||||||
|
self.assertIsNone(items[0]["author"])
|
||||||
|
|
||||||
|
def test_changelog_items_custom_field_lookup(self):
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
def field_id_of(name):
|
||||||
|
asked.append(name)
|
||||||
|
return "customfield_10201"
|
||||||
|
h = [{"id": "1", "author": {"name": "p"}, "created": "2026-10-22T12:00:00.000+0300",
|
||||||
|
"items": [_st("Плановое окончание", "2026-10-25", "2026-10-30", fieldtype="custom"),
|
||||||
|
_st("Sprint", "1", "2", fieldtype="custom", fieldId="customfield_10104"),
|
||||||
|
_st("duedate", "2026-10-25", "2026-10-30")]}]
|
||||||
|
items = self.ex.changelog_items("X", h, None, field_id_of)
|
||||||
|
self.assertEqual([i["field_id"] for i in items], ["customfield_10201", "customfield_10104", "duedate"])
|
||||||
|
self.assertEqual(asked, ["Плановое окончание"])
|
||||||
|
|
||||||
|
def test_changelog_items_drops_bad(self):
|
||||||
|
h = [{"id": None, "author": None, "created": "2026-10-22T12:00:00.000+0300", "items": [_st("status", "a", "b")]},
|
||||||
|
{"id": "2", "author": None, "created": "", "items": [_st("status", "a", "b")]},
|
||||||
|
{"id": "3", "author": None, "created": "2026-10-22T12:00:00.000+0300",
|
||||||
|
"items": [_st("", "a", "b"), _st("status", 1, 2)]}]
|
||||||
|
items = self.ex.changelog_items("X", h, None, lambda n: None)
|
||||||
|
self.assertEqual([(i["history_id"], i["seq"], i["from"], i["to"]) for i in items], [("3", 1, "1", "2")])
|
||||||
|
self.assertEqual(len([l for l in self.logs if "X" in l]), 3)
|
||||||
|
|
||||||
|
def test_changelog_items_no_since(self):
|
||||||
|
h = [{"id": str(n), "author": None, "created": "2020-01-01T00:00:00.000+0300", "items": [_st("status", "a", "b")]}
|
||||||
|
for n in range(3)]
|
||||||
|
self.assertEqual(len(self.ex.changelog_items("X", h, None, lambda n: None)), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeChangelogTest(Base):
|
||||||
|
def test_fake_search_expands_changelog(self):
|
||||||
|
self.fake.add("AURORA-1")
|
||||||
|
for n in range(3):
|
||||||
|
self.fake.add_history("AURORA-1", "2026-10-22T12:00:00.000+0300", [_st("status", "a", "b")])
|
||||||
|
self.fake.changelog_cap = 2
|
||||||
|
page = self.jira.get(agent.API + "/search", jql="key in (AURORA-1)", fields="key", expand="changelog")
|
||||||
|
cl = page["issues"][0]["changelog"]
|
||||||
|
self.assertEqual((cl["total"], len(cl["histories"])), (3, 2))
|
||||||
|
self.fake.issue_changelog_cap = 3
|
||||||
|
full = self.jira.get(agent.API + "/issue/AURORA-1", fields="key", expand="changelog")
|
||||||
|
self.assertEqual(len(full["changelog"]["histories"]), 3)
|
||||||
|
self.assertNotIn("changelog", self.jira.get(agent.API + "/issue/AURORA-1"))
|
||||||
|
self.fake.jql_results["x AND sprint in openSprints()"] = ["AURORA-1"]
|
||||||
|
self.assertEqual(self.fake.search_keys("x AND sprint in openSprints()"), ["AURORA-1"])
|
||||||
|
self.assertTrue(any(f["name"] == "Плановое окончание" for f in self.jira.get(agent.API + "/field")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
x
Reference in New Issue
Block a user